This commit is contained in:
2026-06-06 23:54:11 +08:00
commit 33639129b1
58 changed files with 10309 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.venv/
.runtime/
__pycache__/
*.pyc
node_modules/
frontend/dist/
backend/data/
backend/uploads/
*.sqlite3
.env
+42
View File
@@ -0,0 +1,42 @@
# Dependencies
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/.tsbuildinfo/
backend/.venv/
.venv/
# Python
__pycache__/
backend/__pycache__/
backend/**/*.pyc
*.pyc
# Runtime & cache
.runtime/
.ruff_cache/
.playwright-cli/
output/
# Build artifacts
*.tsbuildinfo
# Environment (contains secrets)
backend/.env
.env
# Database & uploads
backend/data/
backend/uploads/
*.sqlite3
# Tooling
.agents/
.opencode/
.codex/
openspec
# Reference materials
/ref/
第一小组*.docx
第一小组*.pptx
+3
View File
@@ -0,0 +1,3 @@
{
"python.analysis.extraPaths": ["backend"]
}
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY backend/ ./backend/
WORKDIR /app/backend
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+13
View File
@@ -0,0 +1,13 @@
FROM node:lts-alpine AS builder
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM nginx:stable-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+59
View File
@@ -0,0 +1,59 @@
# 交互式透明化宿舍报修系统
课程项目 —— 带 AI 诊断引导的宿舍报修流程。
## 快速开始
```bash
./cli.py setup # 安装依赖
./cli.py env # 创建 .env(可选,未配置 DeepSeek 则使用关键词诊断)
./cli.py start # 启动前后端
```
## 常用命令
```bash
./cli.py start # 启动服务
./cli.py stop # 停止服务
./cli.py status # 查看状态
./cli.py logs backend -f # 实时日志
./cli.py seed # 写入演示数据
./cli.py docker up # Docker 部署
```
## 服务地址
| 服务 | 地址 |
| --- | ---- |
| 前端 | <http://127.0.0.1:5173> |
| 后端 API | <http://127.0.0.1:8000> |
| API 文档 | <http://127.0.0.1:8000/docs> |
## 演示账号
运行 `./cli.py seed` 写入演示数据:
| 角色 | 用户名 | 密码 |
|------|--------|------|
| 学生 | `student01` | `Student123` |
| 管理员 | `admin01` | `Admin123` |
## 环境变量
`./cli.py env` 会从 `.env.example` 复制模板。主要配置项见 `backend/.env.example`
关键变量:`DEEPSEEK_API_KEY` — 配置后启用 AI 诊断,留空则降级为关键词匹配。
## 主流程
1. 学生用自然语言描述故障 → AI/关键词诊断追问 → 生成结构化草稿 → 确认提交
2. 管理员审核工单、分配人员、更新状态
3. 学生确认完工并评价
## 技术栈
```
后端 FastAPI + SQLite + DeepSeek API
前端 Vue 3 + Element Plus + Vite
管理 ./cli.py 统一入口 | Docker Compose 可选
```
+11
View File
@@ -0,0 +1,11 @@
# 数据库文件路径(相对于 backend/ 目录),默认 dorm_repair.sqlite3
# DORM_REPAIR_DB=dorm_repair.sqlite3
# 前端地址,用于 CORS
# FRONTEND_ORIGIN=http://localhost:5173
# DeepSeek API 配置(填入 Key 后启用 AI 智能诊断,否则自动降级到关键词匹配)
DEEPSEEK_API_KEY=
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# DEEPSEEK_MODEL=deepseek-chat
# DEEPSEEK_TIMEOUT=30
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from app.core.database import get_connection
from app.core.schemas import UserProfile
from app.services import repository
def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_token")
return authorization.replace("Bearer ", "", 1).strip()
def get_current_user(token: Annotated[str, Depends(get_bearer_token)]) -> UserProfile:
with get_connection() as connection:
row = repository.get_user_by_token(connection, token)
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
return repository.profile_from_row(row)
def require_student(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
if user.role != "student":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="student_only")
return user
def require_admin(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_only")
return user
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.deps import require_admin
from app.core.database import get_connection
from app.core.schemas import AdminOrderUpdateRequest, OrderDetailOut, OrderSummaryOut, UserProfile
from app.services import repository
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/orders", response_model=list[OrderSummaryOut])
def list_orders(
_: Annotated[UserProfile, Depends(require_admin)],
status_text: str | None = Query(default=None, alias="status"),
category: str | None = Query(default=None),
urgency: str | None = Query(default=None),
) -> list[dict[str, str]]:
with get_connection() as connection:
return repository.list_orders(connection, status=status_text, category=category, urgency=urgency)
@router.get("/orders/{order_id}", response_model=OrderDetailOut)
def get_order(order_id: int, _: Annotated[UserProfile, Depends(require_admin)]) -> dict[str, str]:
with get_connection() as connection:
detail = repository.get_order_detail(connection, order_id)
if detail is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
return detail
@router.patch("/orders/{order_id}")
def update_order(
order_id: int,
payload: AdminOrderUpdateRequest,
admin: Annotated[UserProfile, Depends(require_admin)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.update_order(connection, order_id, admin, payload)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return {"status": "ok"}
@router.get("/stats")
def get_stats(_: Annotated[UserProfile, Depends(require_admin)]) -> dict[str, object]:
with get_connection() as connection:
return repository.get_stats(connection)
@router.post("/orders/{order_id}/accept-rework")
def accept_rework(
order_id: int,
admin: Annotated[UserProfile, Depends(require_admin)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.accept_rework(connection, order_id, admin)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return {"status": "ok"}
@router.post("/orders/{order_id}/reject-rework")
def reject_rework(
order_id: int,
admin: Annotated[UserProfile, Depends(require_admin)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.reject_rework(connection, order_id, admin)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return {"status": "ok"}
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import get_current_user
from app.core.database import get_connection
from app.core.schemas import LoginRequest, LoginResponse, UserProfile
from app.core.security import verify_password
from app.services import repository
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest) -> LoginResponse:
with get_connection() as connection:
row = connection.execute(
"SELECT id, username, password_hash, role, display_name FROM users WHERE username = ?",
(payload.username,),
).fetchone()
if row is None or not verify_password(payload.password, row["password_hash"]):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials")
token = repository.create_session(connection, row["id"])
user = repository.profile_from_row(row)
return LoginResponse(token=token, user=user)
@router.get("/me", response_model=UserProfile)
def me(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
return user
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from app.api.deps import require_student
from app.core.database import get_connection
from app.core.ratelimit import check_diagnosis_rate_limit
from app.core.schemas import (
DiagnosisAnswerRequest,
DiagnosisResponse,
DiagnosisStartRequest,
FeedbackRequest,
OrderCreateRequest,
OrderDetailOut,
OrderSummaryOut,
ReworkRequest,
SavedAddressOut,
UserProfile,
)
from app.services import repository
from app.services.diagnosis import get_diagnosis_provider
router = APIRouter(prefix="/api/student", tags=["student"])
@router.post("/diagnosis/start", response_model=DiagnosisResponse)
async def start_diagnosis(
payload: DiagnosisStartRequest,
_: Annotated[UserProfile, Depends(require_student)],
_rl: None = Depends(check_diagnosis_rate_limit),
) -> DiagnosisResponse:
provider = get_diagnosis_provider()
return await provider.start(payload.message)
@router.post("/diagnosis/answer", response_model=DiagnosisResponse)
async def answer_diagnosis(
payload: DiagnosisAnswerRequest,
_: Annotated[UserProfile, Depends(require_student)],
_rl: None = Depends(check_diagnosis_rate_limit),
) -> DiagnosisResponse:
try:
provider = get_diagnosis_provider()
return await provider.answer(payload.session_id, payload.answers)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
@router.get("/addresses", response_model=list[SavedAddressOut])
def list_addresses(user: Annotated[UserProfile, Depends(require_student)]) -> list[dict[str, object]]:
with get_connection() as connection:
return repository.list_addresses(connection, user.id)
@router.delete("/addresses/{address_id}")
def delete_address(address_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
with get_connection() as connection:
if not repository.delete_address(connection, address_id, user.id):
raise HTTPException(status_code=404, detail="地址不存在")
return {"message": "已删除"}
@router.post("/orders")
def create_order(payload: OrderCreateRequest, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, int]:
with get_connection() as connection:
order_id = repository.create_order(connection, user, payload)
return {"order_id": order_id}
@router.post("/orders/{order_id}/attachments")
def upload_attachments(
order_id: int,
user: Annotated[UserProfile, Depends(require_student)],
files: Annotated[list[UploadFile], File(...)],
) -> dict[str, str]:
with get_connection() as connection:
order = repository.get_order_detail(connection, order_id, student_id=user.id)
if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
for file in files:
try:
repository.save_attachment(connection, order_id, file)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return {"status": "ok"}
@router.post("/orders/{order_id}/cancel")
def cancel_order(
order_id: int,
user: Annotated[UserProfile, Depends(require_student)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.cancel_order(connection, order_id, user)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
return {"status": "ok"}
@router.get("/orders", response_model=list[OrderSummaryOut])
def list_orders(user: Annotated[UserProfile, Depends(require_student)]) -> list[dict[str, str]]:
with get_connection() as connection:
return repository.list_orders(connection, student_id=user.id)
@router.get("/orders/{order_id}", response_model=OrderDetailOut)
def get_order(order_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
with get_connection() as connection:
detail = repository.get_order_detail(connection, order_id, student_id=user.id)
if detail is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
return detail
@router.post("/orders/{order_id}/confirm")
def confirm_order(order_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
with get_connection() as connection:
try:
repository.confirm_order(connection, order_id, user)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
return {"status": "ok"}
@router.post("/orders/{order_id}/feedback")
def submit_feedback(
order_id: int,
payload: FeedbackRequest,
user: Annotated[UserProfile, Depends(require_student)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.add_feedback(connection, order_id, user, payload)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
return {"status": "ok"}
@router.post("/orders/{order_id}/rework")
def submit_rework(
order_id: int,
payload: ReworkRequest,
user: Annotated[UserProfile, Depends(require_student)],
) -> dict[str, str]:
with get_connection() as connection:
try:
repository.request_rework(connection, order_id, user, payload)
except KeyError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
return {"status": "ok"}
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import sqlite3
from collections.abc import Generator
from contextlib import contextmanager
from .settings import DATABASE_PATH, ensure_runtime_dirs
@contextmanager
def get_connection() -> Generator[sqlite3.Connection, None, None]:
ensure_runtime_dirs()
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
try:
yield connection
finally:
connection.close()
+42
View File
@@ -0,0 +1,42 @@
"""Simple in-memory rate limiter per user."""
from __future__ import annotations
import time
from collections import defaultdict
from typing import Annotated
from fastapi import Depends, HTTPException, status
from app.api.deps import require_student
from app.core.schemas import UserProfile
class RateLimiter:
def __init__(self, max_requests: int, window_seconds: int) -> None:
self._max = max_requests
self._window = window_seconds
self._buckets: dict[int, list[float]] = defaultdict(list)
def check(self, user_id: int) -> None:
now = time.time()
cutoff = now - self._window
bucket = self._buckets[user_id]
# purge expired entries
while bucket and bucket[0] < cutoff:
bucket.pop(0)
if len(bucket) >= self._max:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请 {self._window // 60} 分钟后重试",
)
bucket.append(now)
_diagnosis_limiter = RateLimiter(max_requests=5, window_seconds=60)
def check_diagnosis_rate_limit(
user: Annotated[UserProfile, Depends(require_student)],
) -> None:
_diagnosis_limiter.check(user.id)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
Role = Literal["student", "admin"]
OrderStatus = Literal["已提交", "待处理", "处理中", "待上门", "已完成", "已确认", "返工申请中", "已取消"]
Urgency = Literal["", "", "", "紧急"]
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=50)
password: str = Field(min_length=1, max_length=100)
class UserProfile(BaseModel):
id: int
username: str
display_name: str
role: Role
class LoginResponse(BaseModel):
token: str
user: UserProfile
class DiagnosisStartRequest(BaseModel):
message: str = Field(min_length=2, max_length=300)
class DiagnosisQuestionAnswer(BaseModel):
question_id: str
answer: str = Field(min_length=1, max_length=200)
class DiagnosisAnswerRequest(BaseModel):
session_id: str
answers: list[DiagnosisQuestionAnswer]
class DiagnosisQuestion(BaseModel):
id: str
prompt: str
class DiagnosisDraft(BaseModel):
category: str
urgency: Urgency
summary: str
safety_risk: bool
suggested_worker: str
notes: list[str]
class DiagnosisResponse(BaseModel):
session_id: str
stage: Literal["questions", "draft"]
initial_message: str
suggested_categories: list[str]
questions: list[DiagnosisQuestion]
draft: DiagnosisDraft | None = None
class SavedAddressOut(BaseModel):
id: int
campus: str
building: str
room: str
last_used_at: str
class OrderCreateRequest(BaseModel):
campus: str = Field(min_length=1, max_length=50)
building: str = Field(min_length=1, max_length=50)
room: str = Field(min_length=1, max_length=50)
category: str = Field(min_length=1, max_length=50)
raw_description: str = Field(min_length=2, max_length=300)
structured_summary: str = Field(min_length=2, max_length=400)
urgency: Urgency
expected_date: str | None = Field(default=None, max_length=20)
expected_time_segment: str | None = Field(default=None, max_length=50)
diagnosis_session_id: str | None = Field(default=None, max_length=80)
allow_room_entry: bool = False
class FeedbackRequest(BaseModel):
rating: int = Field(ge=1, le=5)
comment: str = Field(min_length=1, max_length=300)
class ReworkRequest(BaseModel):
reason: str = Field(min_length=2, max_length=300)
class AdminOrderUpdateRequest(BaseModel):
status: OrderStatus | None = None
assignee_name: str | None = Field(default=None, max_length=50)
expected_arrival_at: str | None = Field(default=None, max_length=50)
admin_note: str | None = Field(default=None, max_length=300)
class OrderEventOut(BaseModel):
id: int
actor_role: str
actor_name: str
event_type: str
title: str
detail: str | None
from_status: str | None
to_status: str | None
created_at: str
class AttachmentOut(BaseModel):
id: int
file_name: str
file_path: str
mime_type: str
created_at: str
class OrderSummaryOut(BaseModel):
id: int
order_no: str
campus: str
building: str
room: str
category: str
status: str
urgency: str
submission_time: str
expected_repair_time: str | None
assignee_name: str | None
class OrderDetailOut(BaseModel):
id: int
order_no: str
campus: str
building: str
room: str
category: str
status: str
urgency: str
raw_description: str
structured_summary: str
allow_room_entry: bool
expected_date: str | None
expected_time_segment: str | None
assignee_name: str | None
expected_arrival_at: str | None
admin_note: str | None
rework_reason: str | None
created_at: str
updated_at: str
attachments: list[AttachmentOut]
events: list[OrderEventOut]
feedback: dict[str, Any] | None
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import secrets
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode())
def generate_token() -> str:
return secrets.token_urlsafe(32)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
DATA_DIR = BASE_DIR / "data"
UPLOADS_DIR = BASE_DIR / "uploads"
DATABASE_PATH = DATA_DIR / os.getenv("DORM_REPAIR_DB", "dorm_repair.sqlite3")
FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "http://localhost:5173")
FRONTEND_ORIGINS = [
origin.strip()
for origin in os.getenv("FRONTEND_ORIGINS", f"{FRONTEND_ORIGIN},http://127.0.0.1:5173").split(",")
if origin.strip()
]
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
DEEPSEEK_TIMEOUT = float(os.getenv("DEEPSEEK_TIMEOUT", "30.0"))
def ensure_runtime_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.api.routes_admin import router as admin_router
from app.api.routes_auth import router as auth_router
from app.api.routes_student import router as student_router
from app.core.database import get_connection
from app.core.settings import FRONTEND_ORIGINS, UPLOADS_DIR, ensure_runtime_dirs
from app.services.repository import init_db
logger = logging.getLogger(__name__)
ERROR_DETAIL_MAP: dict[str, str] = {
"missing_token": "缺少认证令牌",
"invalid_token": "认证已过期,请重新登录",
"invalid_credentials": "用户名或密码错误",
"student_only": "此功能仅限学生使用",
"admin_only": "此功能仅限管理员使用",
"order_not_found": "工单不存在或已被删除",
"session_not_found": "诊断会话已过期,请重新描述故障",
"order_not_completed": "维修尚未完成,无法执行此操作",
"order_not_ready_for_feedback": "当前状态不可评价",
"order_cannot_cancel": "当前状态无法取消,仅已提交或待处理的工单可取消",
"order_not_in_rework": "当前工单不在返工申请状态",
"unsupported_file_type": "仅支持上传图片文件(jpg, png, gif, webp",
"file_too_large": "文件大小超过限制(最大10MB",
"validation_error": "请求参数有误",
}
ensure_runtime_dirs()
with get_connection() as conn:
init_db(conn)
conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')")
conn.commit()
app = FastAPI(title="Dorm Repair API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=FRONTEND_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(student_router)
app.include_router(admin_router)
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
error_code = str(exc.detail) if not isinstance(exc.detail, dict) else exc.detail.get("error_code", str(exc.detail))
detail = ERROR_DETAIL_MAP.get(error_code, str(exc.detail))
return JSONResponse(
status_code=exc.status_code,
content={"detail": detail, "error_code": error_code},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content={"detail": "请求参数有误", "error_code": "validation_error"},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled exception: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "服务器内部错误", "error_code": "internal_error"},
)
@app.on_event("shutdown")
async def shutdown() -> None:
from app.services.diagnosis import close_diagnosis_provider
await close_diagnosis_provider()
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Literal
from uuid import uuid4
import httpx
from app.core.schemas import DiagnosisDraft, DiagnosisQuestion, DiagnosisQuestionAnswer, DiagnosisResponse
from app.core.settings import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, DEEPSEEK_TIMEOUT
logger = logging.getLogger(__name__)
SESSION_TTL = 3600 # 1 hour
CLASSIFY_SYSTEM_PROMPT = """你是宿舍维修故障诊断助手。根据学生故障描述,输出 JSON 对象。
分类规则:
- 电路照明: 灯、电、跳闸、插座、焦味、漏电、火花
- 给排水: 漏水、水龙头、下水、厕所、马桶、管道、洗手池
- 门窗锁具: 门、锁、窗、把手、柜门
- 空调设备: 空调、制冷、制热
- 网络设备: 网络、wifi、网速、断网、网口
- 家具设施: 床、桌椅、衣柜、抽屉、护栏
- 其他: 不属于以上类别
紧急度规则(urgency):
- 紧急: 火花、漏电、冒烟、大面积漏水、安全威胁
- 高: 影响基本生活(照明/用水/用电/门禁)
- 中: 普通故障影响使用
- 低: 无立即影响
safety_risk: 火花、漏电、冒烟、焦味 设为 true
suggested_worker: 电工(电路照明)、水暖维修(给排水)、门窗维修(门窗锁具)、空调维修(空调设备)、
网络维护(网络设备)、综合维修(其他)
questions: 2-3个有针对性、非模板化的追问,每个含 id(q1/q2/q3) 和 prompt 字段
输出格式:
{"category":"...","urgency":"...","safety_risk":false,"suggested_worker":"...","notes":["..."],"questions":[{"id":"q1","prompt":"..."}]}"""
SUMMARY_SYSTEM_PROMPT = """根据学生原始描述和补充回答,生成维修工单摘要。
格式:"[category]问题,建议[suggested_worker]处理。[现象][位置][影响范围]"
不超过200字,使用中文标点。"""
@dataclass
class DiagnosisSession:
session_id: str
category: str
urgency: Literal["", "", "", "紧急"]
suggested_worker: str
safety_risk: bool
initial_message: str
suggested_categories: list[str]
questions: list[DiagnosisQuestion]
notes: list[str] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
class AIDiagnosisProvider:
def __init__(
self,
*,
base_url: str = DEEPSEEK_BASE_URL,
api_key: str = DEEPSEEK_API_KEY,
model: str = DEEPSEEK_MODEL,
timeout: float = DEEPSEEK_TIMEOUT,
) -> None:
self._api_key = api_key
self._client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=httpx.Timeout(timeout),
)
self._model = model
self._sessions: dict[str, DiagnosisSession] = {}
self._last_sweep = time.time()
def _sweep_expired_sessions(self) -> None:
now = time.time()
if now - self._last_sweep < 300: # sweep every 5 minutes
return
self._last_sweep = now
expired = [sid for sid, s in self._sessions.items() if now - s.created_at > SESSION_TTL]
for sid in expired:
del self._sessions[sid]
if expired:
logger.info("Swept %d expired diagnosis sessions", len(expired))
async def start(self, message: str) -> DiagnosisResponse:
self._sweep_expired_sessions()
if not self._api_key:
return self._start_local(message)
try:
result = await self._call_classify(message)
category = result["category"]
urgency = result["urgency"]
worker = result["suggested_worker"]
safety_risk = result["safety_risk"]
ai_notes = result.get("notes", [])
questions = [DiagnosisQuestion(id=q["id"], prompt=q["prompt"]) for q in result.get("questions", [])]
if not questions:
questions = [
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
]
except (httpx.HTTPError, json.JSONDecodeError, KeyError):
return self._start_local(message)
session_id = uuid4().hex
suggested_categories = [category]
if category != "其他":
suggested_categories.append("其他")
session = DiagnosisSession(
session_id=session_id,
category=category,
urgency=urgency,
suggested_worker=worker,
safety_risk=safety_risk,
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
notes=ai_notes,
)
self._sessions[session_id] = session
return DiagnosisResponse(
session_id=session_id,
stage="questions",
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
draft=None,
)
async def answer(self, session_id: str, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
session = self._sessions.get(session_id)
if session is None:
raise KeyError("session_not_found")
if time.time() - session.created_at > SESSION_TTL:
del self._sessions[session_id]
raise KeyError("session_not_found")
session.created_at = time.time()
if not self._api_key:
return self._answer_local(session, answers)
try:
summary = await self._call_summary(session, answers)
except (httpx.HTTPError, json.JSONDecodeError):
return self._answer_local(session, answers)
notes = list(session.notes)
if session.safety_risk:
notes.append("建议尽快断电或停止继续使用相关设备。")
answer_map = {item.question_id: item.answer.strip() for item in answers}
for question in session.questions:
value = answer_map.get(question.id)
if value:
notes.append(f"{question.prompt}{value}")
draft = DiagnosisDraft(
category=session.category,
urgency=session.urgency,
summary=summary,
safety_risk=session.safety_risk,
suggested_worker=session.suggested_worker,
notes=notes,
)
return DiagnosisResponse(
session_id=session.session_id,
stage="draft",
initial_message=session.initial_message,
suggested_categories=session.suggested_categories,
questions=[],
draft=draft,
)
def _start_local(self, message: str) -> DiagnosisResponse:
category, urgency, worker, safety_risk, questions = self._local_classify(message)
session_id = uuid4().hex
suggested_categories = [category]
if category != "其他":
suggested_categories.append("其他")
session = DiagnosisSession(
session_id=session_id,
category=category,
urgency=urgency,
suggested_worker=worker,
safety_risk=safety_risk,
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
)
self._sessions[session_id] = session
return DiagnosisResponse(
session_id=session_id,
stage="questions",
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
draft=None,
)
def _answer_local(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
answer_map = {item.question_id: item.answer.strip() for item in answers}
notes = []
if session.safety_risk:
notes.append("建议尽快断电或停止继续使用相关设备。")
if "location" in answer_map:
notes.append(f"具体位置:{answer_map['location']}")
if "impact" in answer_map:
notes.append(f"影响范围:{answer_map['impact']}")
if "symptom" in answer_map:
notes.append(f"补充现象:{answer_map['symptom']}")
summary = self._local_build_summary(session, answer_map)
draft = DiagnosisDraft(
category=session.category,
urgency=session.urgency,
summary=summary,
safety_risk=session.safety_risk,
suggested_worker=session.suggested_worker,
notes=notes,
)
return DiagnosisResponse(
session_id=session.session_id,
stage="draft",
initial_message=session.initial_message,
suggested_categories=session.suggested_categories,
questions=[],
draft=draft,
)
def _local_classify(
self, message: str
) -> tuple[str, Literal["", "", "", "紧急"], str, bool, list[DiagnosisQuestion]]:
text = message.lower()
category = "其他"
urgency: Literal["", "", "", "紧急"] = ""
worker = "综合维修"
safety_risk = False
if any(keyword in text for keyword in ["", "", "跳闸", "插座", "焦味"]):
category = "电路照明"
worker = "电工"
urgency = ""
if any(keyword in text for keyword in ["漏水", "水龙头", "下水", "厕所", "马桶"]):
category = "给排水"
worker = "水暖维修"
urgency = ""
if any(keyword in text for keyword in ["", "", "", "把手"]):
category = "门窗锁具"
worker = "门窗维修"
urgency = ""
if any(keyword in text for keyword in ["空调", "制冷", "制热"]):
category = "空调设备"
worker = "空调维修"
urgency = ""
if any(keyword in text for keyword in ["网络", "wifi", "网速", "断网"]):
category = "网络设备"
worker = "网络维护"
urgency = ""
if any(keyword in text for keyword in ["", "火花", "焦味", "漏电"]):
urgency = "紧急"
safety_risk = True
questions = [
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
DiagnosisQuestion(id="impact", prompt="这个问题目前影响范围有多大?比如是否影响整间宿舍使用。"),
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
]
return category, urgency, worker, safety_risk, questions
def _local_build_summary(self, session: DiagnosisSession, answers: dict[str, str]) -> str:
parts = [session.initial_message.strip()]
for key in ("location", "impact", "symptom"):
value = answers.get(key)
if value:
parts.append(value)
joined = "".join(parts)
return f"{session.category}问题,建议{session.suggested_worker}处理。{joined}"
async def _call_classify(self, message: str) -> dict[str, Any]:
response = await self._client.post(
"/v1/chat/completions",
json={
"model": self._model,
"messages": [
{"role": "system", "content": CLASSIFY_SYSTEM_PROMPT},
{"role": "user", "content": message},
],
"response_format": {"type": "json_object"},
"temperature": 0.1,
},
)
response.raise_for_status()
body = response.json()
content = body["choices"][0]["message"]["content"]
result = json.loads(content)
_validate_classify_result(result)
return result
async def _call_summary(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> str:
answer_texts = "\n".join(f"- {item.question_id}: {item.answer.strip()}" for item in answers)
response = await self._client.post(
"/v1/chat/completions",
json={
"model": self._model,
"messages": [
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"原始描述:{session.initial_message}\n"
f"故障类别:{session.category}\n"
f"负责工种:{session.suggested_worker}\n"
f"补充回答:\n{answer_texts}"
),
},
],
"temperature": 0.1,
},
)
response.raise_for_status()
body = response.json()
return body["choices"][0]["message"]["content"].strip()
def _validate_classify_result(result: dict[str, Any]) -> None:
valid_categories = {"电路照明", "给排水", "门窗锁具", "空调设备", "网络设备", "家具设施", "其他"}
valid_urgencies = {"", "", "", "紧急"}
category = result.get("category", "")
if category not in valid_categories:
result["category"] = "其他"
urgency = result.get("urgency", "")
if urgency not in valid_urgencies:
result["urgency"] = ""
if not isinstance(result.get("safety_risk"), bool):
result["safety_risk"] = False
worker = result.get("suggested_worker", "")
if not worker:
result["suggested_worker"] = "综合维修"
questions = result.get("questions")
if not isinstance(questions, list):
result["questions"] = []
else:
result["questions"] = [q for q in questions if isinstance(q, dict) and "id" in q and "prompt" in q]
notes = result.get("notes")
if not isinstance(notes, list):
result["notes"] = []
_dp: AIDiagnosisProvider | None = None
def get_diagnosis_provider() -> AIDiagnosisProvider:
global _dp
if _dp is None:
_dp = AIDiagnosisProvider()
return _dp
async def close_diagnosis_provider() -> None:
global _dp
if _dp is not None:
await _dp._client.aclose()
_dp = None
+662
View File
@@ -0,0 +1,662 @@
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import UploadFile
from app.core.schemas import AdminOrderUpdateRequest, FeedbackRequest, OrderCreateRequest, ReworkRequest, UserProfile
from app.core.security import generate_token
from app.core.settings import UPLOADS_DIR
STATUS_NEW = "已提交"
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
def now_text() -> str:
return datetime.now().strftime(DATETIME_FORMAT)
def init_db(connection: sqlite3.Connection) -> None:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS repair_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT UNIQUE NOT NULL,
student_id INTEGER NOT NULL,
campus TEXT NOT NULL,
building TEXT NOT NULL,
room TEXT NOT NULL,
category TEXT NOT NULL,
raw_description TEXT NOT NULL,
structured_summary TEXT NOT NULL,
urgency TEXT NOT NULL,
status TEXT NOT NULL,
allow_room_entry INTEGER NOT NULL DEFAULT 0,
assignee_name TEXT,
expected_date TEXT,
expected_time_segment TEXT,
expected_arrival_at TEXT,
admin_note TEXT,
rework_reason TEXT,
diagnosis_session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(student_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS saved_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
campus TEXT NOT NULL,
building TEXT NOT NULL,
room TEXT NOT NULL,
last_used_at TEXT NOT NULL,
UNIQUE(student_id, campus, building, room),
FOREIGN KEY(student_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
CREATE TABLE IF NOT EXISTS order_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
actor_role TEXT NOT NULL,
actor_name TEXT NOT NULL,
event_type TEXT NOT NULL,
title TEXT NOT NULL,
detail TEXT,
from_status TEXT,
to_status TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER UNIQUE NOT NULL,
rating INTEGER NOT NULL,
comment TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
"""
)
connection.commit()
def create_session(connection: sqlite3.Connection, user_id: int) -> str:
token = generate_token()
created_at = now_text()
expires_at = (datetime.now() + timedelta(days=7)).strftime(DATETIME_FORMAT)
connection.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, user_id, created_at, expires_at),
)
connection.commit()
return token
def get_user_by_token(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT users.id, users.username, users.display_name, users.role
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token = ? AND sessions.expires_at >= ?
""",
(token, now_text()),
).fetchone()
def profile_from_row(row: sqlite3.Row) -> UserProfile:
return UserProfile(id=row["id"], username=row["username"], display_name=row["display_name"], role=row["role"])
def build_order_number(connection: sqlite3.Connection) -> str:
count = connection.execute("SELECT COUNT(*) AS count FROM repair_orders").fetchone()["count"] + 1
return f"DR{datetime.now().strftime('%Y%m%d')}{count:03d}"
def create_event(
connection: sqlite3.Connection,
order_id: int,
actor_role: str,
actor_name: str,
event_type: str,
title: str,
detail: str | None,
from_status: str | None,
to_status: str | None,
) -> None:
connection.execute(
"""
INSERT INTO order_events (
order_id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
order_id,
actor_role,
actor_name,
event_type,
title,
detail,
from_status,
to_status,
now_text(),
),
)
def upsert_saved_address(
connection: sqlite3.Connection,
student_id: int,
campus: str,
building: str,
room: str,
) -> None:
connection.execute(
"""
INSERT INTO saved_addresses (student_id, campus, building, room, last_used_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(student_id, campus, building, room)
DO UPDATE SET last_used_at = excluded.last_used_at
""",
(student_id, campus, building, room, now_text()),
)
def create_order(connection: sqlite3.Connection, student: UserProfile, payload: OrderCreateRequest) -> int:
order_no = build_order_number(connection)
created_at = now_text()
cursor = connection.execute(
"""
INSERT INTO repair_orders (
order_no, student_id, campus, building, room, category, raw_description, structured_summary,
urgency, status, allow_room_entry, assignee_name, expected_date, expected_time_segment,
expected_arrival_at, admin_note, rework_reason, diagnosis_session_id, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, ?, ?, ?)
""",
(
order_no,
student.id,
payload.campus,
payload.building,
payload.room,
payload.category,
payload.raw_description,
payload.structured_summary,
payload.urgency,
STATUS_NEW,
int(payload.allow_room_entry),
payload.expected_date,
payload.expected_time_segment,
payload.diagnosis_session_id,
created_at,
created_at,
),
)
order_id = cursor.lastrowid
create_event(
connection,
order_id,
"student",
student.display_name,
"created",
"学生提交报修",
payload.raw_description,
None,
STATUS_NEW,
)
upsert_saved_address(connection, student.id, payload.campus, payload.building, payload.room)
connection.commit()
return order_id
def list_addresses(connection: sqlite3.Connection, student_id: int) -> list[dict[str, Any]]:
rows = connection.execute(
"""
SELECT id, campus, building, room, last_used_at
FROM saved_addresses
WHERE student_id = ?
ORDER BY last_used_at DESC
""",
(student_id,),
).fetchall()
return [dict(row) for row in rows]
def delete_address(connection: sqlite3.Connection, address_id: int, student_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM saved_addresses WHERE id = ? AND student_id = ?",
(address_id, student_id),
)
connection.commit()
return cursor.rowcount > 0
def list_orders(
connection: sqlite3.Connection,
*,
student_id: int | None = None,
status: str | None = None,
category: str | None = None,
urgency: str | None = None,
) -> list[dict[str, Any]]:
query = """
SELECT
id,
order_no,
campus,
building,
room,
category,
status,
urgency,
created_at AS submission_time,
COALESCE(
expected_arrival_at,
expected_date || ' ' || COALESCE(expected_time_segment, '')
) AS expected_repair_time,
assignee_name
FROM repair_orders
WHERE 1 = 1
"""
params: list[Any] = []
if student_id is not None:
query += " AND student_id = ?"
params.append(student_id)
if status:
query += " AND status = ?"
params.append(status)
if category:
query += " AND category = ?"
params.append(category)
if urgency:
query += " AND urgency = ?"
params.append(urgency)
query += " ORDER BY updated_at DESC"
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows]
def _get_order_row(connection: sqlite3.Connection, order_id: int) -> sqlite3.Row | None:
return connection.execute("SELECT * FROM repair_orders WHERE id = ?", (order_id,)).fetchone()
def get_order_detail(
connection: sqlite3.Connection,
order_id: int,
*,
student_id: int | None = None,
) -> dict[str, Any] | None:
row = _get_order_row(connection, order_id)
if row is None:
return None
if student_id is not None and row["student_id"] != student_id:
return None
attachments = connection.execute(
"SELECT id, file_name, file_path, mime_type, created_at FROM attachments WHERE order_id = ? ORDER BY id ASC",
(order_id,),
).fetchall()
events = connection.execute(
"""
SELECT id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
FROM order_events
WHERE order_id = ?
ORDER BY id ASC
""",
(order_id,),
).fetchall()
feedback_row = connection.execute(
"SELECT rating, comment, created_at FROM feedback WHERE order_id = ?",
(order_id,),
).fetchone()
detail = dict(row)
detail["attachments"] = [dict(item) for item in attachments]
detail["events"] = [dict(item) for item in events]
detail["feedback"] = dict(feedback_row) if feedback_row else None
return detail
_ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
_VALID_TRANSITIONS: dict[str, set[str]] = {
"已提交": {"待处理", "已取消"},
"待处理": {"处理中", "已取消"},
"处理中": {"待上门", "已完成"},
"待上门": {"处理中", "已完成"},
"已完成": {"已确认", "返工申请中"},
"已确认": set(),
"返工申请中": {"处理中"},
"已取消": set(),
}
def _detect_image_type(header: bytes) -> str | None:
if header[:3] == b"\xff\xd8\xff":
return ".jpg"
if header[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if header[:4] == b"GIF8":
return ".gif"
if header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP":
return ".webp"
return None
def _validate_status_transition(from_status: str, to_status: str) -> None:
allowed = _VALID_TRANSITIONS.get(from_status, set())
if to_status not in allowed:
raise ValueError(f"不允许的状态转换:{from_status}{to_status}")
def save_attachment(connection: sqlite3.Connection, order_id: int, file: UploadFile) -> None:
ext = Path(file.filename or "upload").suffix.lower() or ".jpg"
if ext not in _ALLOWED_EXTENSIONS:
raise ValueError("unsupported_file_type")
raw = file.file.read()
if len(raw) > _MAX_UPLOAD_SIZE:
raise ValueError("file_too_large")
detected = _detect_image_type(raw[:12])
if detected is None:
raise ValueError("unsupported_file_type")
relative_dir = Path(str(order_id))
target_dir = UPLOADS_DIR / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
file_name = f"{uuid4().hex}{detected}"
target_path = target_dir / file_name
target_path.write_bytes(raw)
connection.execute(
"""
INSERT INTO attachments (order_id, file_name, file_path, mime_type, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(order_id, file.filename or file_name, str(relative_dir / file_name), f"image/{detected[1:]}", now_text()),
)
connection.commit()
def confirm_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] != "已完成":
raise ValueError("order_not_completed")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
("已确认", now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"confirmed",
"学生确认维修完成",
None,
previous,
"已确认",
)
connection.commit()
def request_rework(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: ReworkRequest) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] != "已完成":
raise ValueError("order_not_completed")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = ?, updated_at = ? WHERE id = ?",
("返工申请中", payload.reason, now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"rework_requested",
"学生申请返工",
payload.reason,
previous,
"返工申请中",
)
connection.commit()
def add_feedback(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: FeedbackRequest) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] not in {"已完成", "已确认"}:
raise ValueError("order_not_ready_for_feedback")
connection.execute(
"INSERT OR REPLACE INTO feedback (order_id, rating, comment, created_at) VALUES (?, ?, ?, ?)",
(order_id, payload.rating, payload.comment, now_text()),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"feedback_added",
"学生提交评价",
payload.comment,
None,
None,
)
connection.commit()
def update_order(
connection: sqlite3.Connection, order_id: int, admin: UserProfile, payload: AdminOrderUpdateRequest
) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
previous_status = row["status"]
assignee_name = payload.assignee_name if payload.assignee_name is not None else row["assignee_name"]
expected_arrival_at = (
payload.expected_arrival_at if payload.expected_arrival_at is not None else row["expected_arrival_at"]
)
admin_note = payload.admin_note if payload.admin_note is not None else row["admin_note"]
status = payload.status if payload.status is not None else row["status"]
if payload.status is not None and payload.status != row["status"]:
_validate_status_transition(row["status"], payload.status)
connection.execute(
"""
UPDATE repair_orders
SET status = ?, assignee_name = ?, expected_arrival_at = ?, admin_note = ?, updated_at = ?
WHERE id = ?
""",
(status, assignee_name, expected_arrival_at, admin_note, now_text(), order_id),
)
detail_parts = []
if assignee_name:
detail_parts.append(f"负责人:{assignee_name}")
if expected_arrival_at:
detail_parts.append(f"预计上门:{expected_arrival_at}")
if admin_note:
detail_parts.append(f"备注:{admin_note}")
detail = "".join(detail_parts) or None
create_event(
connection,
order_id,
"admin",
admin.display_name,
"admin_updated",
"管理员更新工单",
detail,
previous_status,
status,
)
connection.commit()
def accept_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
if row["status"] != "返工申请中":
raise ValueError("order_not_in_rework")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
("处理中", now_text(), order_id),
)
create_event(
connection,
order_id,
"admin",
admin.display_name,
"rework_accepted",
"管理员接受返工申请,重新安排处理",
None,
previous,
"处理中",
)
connection.commit()
def reject_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
if row["status"] != "返工申请中":
raise ValueError("order_not_in_rework")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
("已完成", now_text(), order_id),
)
create_event(
connection,
order_id,
"admin",
admin.display_name,
"rework_rejected",
"管理员拒绝返工申请,维持已完成状态",
None,
previous,
"已完成",
)
connection.commit()
def cancel_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] not in {"已提交", "待处理"}:
raise ValueError("order_cannot_cancel")
connection.execute(
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
("已取消", now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"status_updated",
"学生取消了报修工单",
None,
row["status"],
"已取消",
)
connection.commit()
def get_stats(connection: sqlite3.Connection) -> dict[str, object]:
cursor = connection.cursor()
cursor.execute(
"""
SELECT category, COUNT(*) AS cnt
FROM repair_orders
GROUP BY category
ORDER BY cnt DESC
"""
)
category_distribution = [{"category": row["category"], "count": row["cnt"]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT building, COUNT(*) AS cnt
FROM repair_orders
GROUP BY building
ORDER BY cnt DESC
"""
)
building_distribution = [{"building": row["building"], "count": row["cnt"]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT AVG(
(julianday(events.first_done_at) - julianday(orders.created_at)) * 24
) AS avg_hours
FROM repair_orders orders
JOIN (
SELECT order_id, MIN(created_at) AS first_done_at
FROM order_events
WHERE to_status = '已完成'
GROUP BY order_id
) events ON events.order_id = orders.id
"""
)
avg_row = cursor.fetchone()
avg_hours = round(avg_row["avg_hours"], 1) if avg_row and avg_row["avg_hours"] is not None else 0
cursor.execute("SELECT AVG(rating) AS avg_rating FROM feedback")
rating_row = cursor.fetchone()
avg_rating = round(rating_row["avg_rating"], 1) if rating_row and rating_row["avg_rating"] is not None else 0
return {
"category_distribution": category_distribution,
"building_distribution": building_distribution,
"avg_processing_hours": avg_hours,
"avg_rating": avg_rating,
}
Executable
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""宿舍报修系统 —— 统一项目管理 CLI
用法:
./cli.py setup 安装全部依赖
./cli.py start [backend|frontend|all] 启动服务
./cli.py stop [backend|frontend|all] 停止服务
./cli.py restart [backend|frontend|all] 重启服务
./cli.py status 查看服务状态
./cli.py logs [backend|frontend] [-f] 查看日志
./cli.py seed [--force] [--db PATH] 写入演示数据
./cli.py lint 运行代码检查
./cli.py typecheck TypeScript 类型检查
./cli.py build 生产构建(前端)
./cli.py clean [--runtime|--db|--deps|--all] 清理临时文件
./cli.py env 创建 .env 配置
./cli.py docker [up|down|build] Docker 快捷操作
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
IS_WINDOWS = sys.platform == "win32"
# Auto-activate virtualenv if present
_VENV = Path(__file__).resolve().parent / ".venv"
_VENV_PYTHON = _VENV / ("Scripts" if IS_WINDOWS else "bin") / ("python.exe" if IS_WINDOWS else "python")
if _VENV_PYTHON.is_file() and Path(sys.executable).resolve() != _VENV_PYTHON.resolve():
os.execv(str(_VENV_PYTHON), [str(_VENV_PYTHON), *sys.argv])
PROJECT_ROOT = Path(__file__).resolve().parent
BACKEND_DIR = PROJECT_ROOT / "backend"
FRONTEND_DIR = PROJECT_ROOT / "frontend"
RUNTIME_DIR = PROJECT_ROOT / ".runtime" / "dev-services"
STATE_PATH = RUNTIME_DIR / "state.json"
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
# ── Color helpers ──────────────────────────────────────────────
_BOLD = "\033[1m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_RED = "\033[31m"
_CYAN = "\033[36m"
_RESET = "\033[0m"
def _green(s: str) -> str:
return f"{_GREEN}{s}{_RESET}"
def _yellow(s: str) -> str:
return f"{_YELLOW}{s}{_RESET}"
def _red(s: str) -> str:
return f"{_RED}{s}{_RESET}"
def _cyan(s: str) -> str:
return f"{_CYAN}{s}{_RESET}"
def _bold(s: str) -> str:
return f"{_BOLD}{s}{_RESET}"
# ── Configuration ──────────────────────────────────────────────
@dataclass(frozen=True)
class Config:
backend_host: str = "127.0.0.1"
backend_port: int = 8000
frontend_host: str = "127.0.0.1"
frontend_port: int = 5173
default_db: Path = BACKEND_DIR / "data" / "dorm_repair.sqlite3"
@property
def backend_url(self) -> str:
return f"http://{self.backend_host}:{self.backend_port}"
@property
def frontend_url(self) -> str:
return f"http://{self.frontend_host}:{self.frontend_port}"
@property
def backend_health_url(self) -> str:
return f"{self.backend_url}/api/health"
CONFIG = Config()
# ── Service definitions ────────────────────────────────────────
@dataclass(frozen=True)
class Service:
name: str
command: list[str]
cwd: Path
url: str
host: str
port: int
env: dict[str, str] | None = None
@property
def log_path(self) -> Path:
return RUNTIME_DIR / f"{self.name}.log"
SERVICES = {
"backend": Service(
name="backend",
command=[
"uv",
"run",
"uvicorn",
"app.main:app",
"--reload",
"--host",
CONFIG.backend_host,
"--port",
str(CONFIG.backend_port),
],
cwd=BACKEND_DIR,
url=CONFIG.backend_health_url,
host=CONFIG.backend_host,
port=CONFIG.backend_port,
env={"UV_CACHE_DIR": str(RUNTIME_DIR / "uv-cache")},
),
"frontend": Service(
name="frontend",
command=[
"pnpm",
"dev",
"--host",
CONFIG.frontend_host,
"--port",
str(CONFIG.frontend_port),
"--strictPort",
],
cwd=FRONTEND_DIR,
url=CONFIG.frontend_url + "/",
host=CONFIG.frontend_host,
port=CONFIG.frontend_port,
),
}
# ── State management ───────────────────────────────────────────
def _load_state() -> dict[str, Any]:
if not STATE_PATH.exists():
return {}
try:
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def _save_state(state: dict[str, Any]) -> None:
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
# ── Process helpers ────────────────────────────────────────────
def _pid_running(pid: int) -> bool:
if IS_WINDOWS:
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
capture_output=True,
text=True,
)
return str(pid) in result.stdout
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _service_pid(state: dict[str, Any], svc: Service) -> int | None:
raw = state.get(svc.name, {}).get("pid")
if not isinstance(raw, int):
return None
return raw if _pid_running(raw) else None
def _port_open(host: str, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((host, port)) == 0
# ── Service lifecycle ──────────────────────────────────────────
def _start_service(svc: Service, state: dict[str, Any]) -> None:
pid = _service_pid(state, svc)
if pid is not None:
print(f" {svc.name}: 已在运行 pid={pid}")
return
if _port_open(svc.host, svc.port):
print(f" {_yellow(svc.name)}: 端口 {svc.host}:{svc.port} 已被占用")
return
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
log_file = svc.log_path.open("ab")
popen_kwargs: dict[str, Any] = {
"cwd": svc.cwd,
"env": {**os.environ, **(svc.env or {})},
"stdin": subprocess.DEVNULL,
"stdout": log_file,
"stderr": subprocess.STDOUT,
}
if IS_WINDOWS and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(svc.command, **popen_kwargs)
state[svc.name] = {
"pid": proc.pid,
"command": svc.command,
"cwd": str(svc.cwd),
"log": str(svc.log_path),
"url": svc.url,
"started_at": time.strftime(DATETIME_FORMAT),
}
print(f" {_green(svc.name)}: 已启动 pid={proc.pid}")
def _stop_service(svc: Service, state: dict[str, Any]) -> None:
pid = _service_pid(state, svc)
if pid is None:
print(f" {svc.name}: 未运行")
state.pop(svc.name, None)
return
print(f" {svc.name}: 正在停止 pid={pid}")
if IS_WINDOWS:
subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True)
else:
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
state.pop(svc.name, None)
return
deadline = time.time() + 8
while time.time() < deadline:
if not _pid_running(pid):
state.pop(svc.name, None)
print(f" {svc.name}: 已停止")
return
time.sleep(0.2)
print(f" {_yellow(svc.name)}: 未响应,强制终止")
if IS_WINDOWS:
subprocess.run(["taskkill", "/F", "/PID", str(pid), "/T"], capture_output=True)
else:
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
state.pop(svc.name, None)
def _status_service(svc: Service, state: dict[str, Any]) -> None:
pid = _service_pid(state, svc)
if pid is None:
suffix = " (端口被占用)" if _port_open(svc.host, svc.port) else ""
print(f" {svc.name}: 未运行{suffix}")
return
print(f" {_green(svc.name)}: 运行中 pid={pid} url={svc.url}")
def _pick_services(names: list[str]) -> list[Service]:
if not names or "all" in names:
return list(SERVICES.values())
selected: list[Service] = []
for name in names:
svc = SERVICES.get(name)
if svc is None:
choices = ", ".join(["all", *SERVICES])
raise SystemExit(f"未知服务 '{name}',可选: {choices}")
selected.append(svc)
return selected
# ── Commands ───────────────────────────────────────────────────
def cmd_setup() -> int:
"""安装后端和前端依赖。"""
print(_bold("安装后端依赖 (uv sync) ..."))
subprocess.run(["uv", "sync"], cwd=BACKEND_DIR, check=True)
print(_bold("安装前端依赖 (pnpm install) ..."))
subprocess.run(["pnpm", "install"], cwd=FRONTEND_DIR, check=True)
print(_green("安装完成。"))
return 0
def cmd_start(services: list[str]) -> int:
state = _load_state()
svcs = _pick_services(services)
for svc in svcs:
_start_service(svc, state)
_save_state(state)
if svcs:
_print_urls(svcs)
return 0
def _print_urls(svcs: list[Service]) -> None:
print()
for svc in svcs:
label = "后端" if svc.name == "backend" else "前端"
print(f" {label}: {_cyan(svc.url)}")
print()
def cmd_stop(services: list[str]) -> int:
state = _load_state()
for svc in reversed(_pick_services(services)):
_stop_service(svc, state)
_save_state(state)
return 0
def cmd_restart(services: list[str]) -> int:
state = _load_state()
svcs = _pick_services(services)
for svc in reversed(svcs):
_stop_service(svc, state)
for svc in svcs:
_start_service(svc, state)
_save_state(state)
if svcs:
_print_urls(svcs)
return 0
def cmd_status() -> int:
state = _load_state()
for svc in SERVICES.values():
_status_service(svc, state)
return 0
def cmd_logs(service: str, follow: bool) -> int:
svc = SERVICES.get(service)
if svc is None:
raise SystemExit(f"未知服务 '{service}',可选: backend, frontend")
log_path = svc.log_path
if not log_path.exists():
print(f"日志文件不存在: {log_path}")
return 0
if follow:
_follow_log(log_path)
else:
content = log_path.read_text(encoding="utf-8", errors="replace")
for line in content.splitlines()[-50:]:
print(line)
return 0
def _follow_log(path: Path) -> None:
if IS_WINDOWS:
with path.open("r", encoding="utf-8", errors="replace") as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
print(line, end="")
else:
time.sleep(0.5)
else:
subprocess.run(["tail", "-n", "50", "-f", str(path)])
def cmd_seed(force: bool, db_path: str | None) -> int:
"""Seed demo data via standalone seed script."""
db = db_path or str(CONFIG.default_db)
seed_script = PROJECT_ROOT / "scripts" / "_seed.py"
print(_bold("Seeding demo data ..."))
subprocess.run(
["uv", "run", "python", str(seed_script), "--db", db] + (["--force"] if force else []),
cwd=BACKEND_DIR,
check=True,
)
print(f" student01 / Student123 {_cyan('(student)')}")
print(f" admin01 / Admin123 {_cyan('(admin)')}")
print(" 8 sample repair orders covering all statuses")
return 0
def cmd_lint() -> int:
print(_bold("Backend lint (ruff) ..."))
subprocess.run(["uv", "run", "ruff", "check", "backend"], check=False)
subprocess.run(["uv", "run", "ruff", "format", "--check", "backend"], check=False)
print(_bold("Frontend lint (eslint) ..."))
subprocess.run(["pnpm", "lint"], cwd=FRONTEND_DIR, check=False)
return 0
def cmd_typecheck() -> int:
print(_bold("TypeScript type check ..."))
subprocess.run(["pnpm", "typecheck"], cwd=FRONTEND_DIR, check=False)
return 0
def cmd_build() -> int:
print(_bold("Building frontend ..."))
subprocess.run(["pnpm", "build"], cwd=FRONTEND_DIR, check=True)
print(_green(f"Output: {FRONTEND_DIR / 'dist'}/"))
return 0
def cmd_clean(runtime: bool, db: bool, deps: bool, all_: bool) -> int:
if all_:
runtime = db = deps = True
if runtime:
if RUNTIME_DIR.exists():
import shutil
shutil.rmtree(RUNTIME_DIR)
print(f"Removed {RUNTIME_DIR}")
else:
print("No runtime dir to clean.")
if db:
db_path = CONFIG.default_db
if db_path.exists():
db_path.unlink()
print(f"Removed {db_path}")
else:
print("No database to clean.")
if deps:
for d in [BACKEND_DIR / ".venv", FRONTEND_DIR / "node_modules"]:
if d.exists():
import shutil
shutil.rmtree(d)
print(f"Removed {d}")
print(_green("Clean complete."))
return 0
def cmd_env() -> int:
for template, target in [
(BACKEND_DIR / ".env.example", BACKEND_DIR / ".env"),
(FRONTEND_DIR / ".env.example", FRONTEND_DIR / ".env"),
]:
if not template.exists():
print(f"Template not found: {template}")
continue
if target.exists():
print(f"{_yellow('Skipped')} {target} (already exists)")
continue
target.write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
print(f"Created {target} from {template.name}")
print(_green("Environment files ready. Edit them if needed:"))
print(f" {BACKEND_DIR / '.env'}")
print(f" {FRONTEND_DIR / '.env'}")
return 0
def cmd_docker(action: str) -> int:
compose_file = PROJECT_ROOT / "docker-compose.yml"
if not compose_file.exists():
raise SystemExit("docker-compose.yml not found.")
if action == "up":
subprocess.run(["docker", "compose", "up", "-d"], check=True)
elif action == "down":
subprocess.run(["docker", "compose", "down"], check=True)
elif action == "build":
subprocess.run(["docker", "compose", "build"], check=True)
else:
raise SystemExit(f"Unknown docker action: {action}")
return 0
# ── Argument parsing ───────────────────────────────────────────
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="宿舍报修系统 —— 项目管理 CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("setup", help="安装全部依赖(uv sync + pnpm install")
for cmd_name in ("start", "stop", "restart"):
label = {"start": "启动", "stop": "停止", "restart": "重启"}[cmd_name]
p = sub.add_parser(cmd_name, help=f"{label}服务")
p.add_argument(
"services",
nargs="*",
default=["all"],
help="目标服务:all, backend, frontend(默认: all",
)
sub.add_parser("status", help="查看服务状态")
log_p = sub.add_parser("logs", help="查看服务日志")
log_p.add_argument("service", choices=["backend", "frontend"], help="服务名")
log_p.add_argument("-f", "--follow", action="store_true", help="实时跟踪日志")
seed_p = sub.add_parser("seed", help="写入演示数据")
seed_p.add_argument("--force", action="store_true", help="覆盖已有数据")
seed_p.add_argument("--db", type=str, default=None, help="SQLite 数据库路径")
sub.add_parser("lint", help="运行代码检查(ruff + eslint")
sub.add_parser("typecheck", help="TypeScript 类型检查(vue-tsc")
sub.add_parser("build", help="生产构建(前端)")
clean_p = sub.add_parser("clean", help="清理临时文件")
clean_p.add_argument("--runtime", action="store_true", help="删除 .runtime/")
clean_p.add_argument("--db", action="store_true", help="删除 SQLite 数据库")
clean_p.add_argument("--deps", action="store_true", help="删除 .venv 和 node_modules")
clean_p.add_argument("--all", action="store_true", help="清理全部")
sub.add_parser("env", help="从 .env.example 创建 .env 配置文件")
docker_p = sub.add_parser("docker", help="Docker 快捷操作")
docker_p.add_argument("action", choices=["up", "down", "build"], help="Docker 操作")
return parser.parse_args(argv)
# ── Main ───────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
cmd = args.command
if cmd == "setup":
return cmd_setup()
elif cmd == "start":
return cmd_start(args.services)
elif cmd == "stop":
return cmd_stop(args.services)
elif cmd == "restart":
return cmd_restart(args.services)
elif cmd == "status":
return cmd_status()
elif cmd == "logs":
return cmd_logs(args.service, args.follow)
elif cmd == "seed":
return cmd_seed(args.force, args.db)
elif cmd == "lint":
return cmd_lint()
elif cmd == "typecheck":
return cmd_typecheck()
elif cmd == "build":
return cmd_build()
elif cmd == "clean":
return cmd_clean(args.runtime, args.db, args.deps, args.all)
elif cmd == "env":
return cmd_env()
elif cmd == "docker":
return cmd_docker(args.action)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+25
View File
@@ -0,0 +1,25 @@
version: "3.8"
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
ports:
- "8000:8000"
volumes:
- ./backend/data:/app/backend/data
- ./backend/uploads:/app/backend/uploads
env_file:
- backend/.env
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
+2
View File
@@ -0,0 +1,2 @@
# 后端 API 地址
VITE_API_BASE_URL=http://127.0.0.1:8000
+43
View File
@@ -0,0 +1,43 @@
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import vue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
export default [
{
ignores: ['dist/**', 'node_modules/**', '.vite/**', '*.tsbuildinfo'],
},
{
files: ['**/*.{js,ts,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
File: 'readonly',
FormData: 'readonly',
KeyboardEvent: 'readonly',
console: 'readonly',
document: 'readonly',
localStorage: 'readonly',
setTimeout: 'readonly',
},
},
},
js.configs.recommended,
...tseslint.configs.recommended,
...vue.configs['flat/essential'],
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
'vue/multi-word-component-names': 'off',
},
},
]
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宿舍报修系统</title>
<meta name="description" content="交互式透明化宿舍报修系统" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231b4f9c'/%3E%3Cpath d='M16 35h32v17H16z' fill='white'/%3E%3Cpath d='M13 33 32 17l19 16' fill='none' stroke='white' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M27 52V40h10v12' fill='%231b4f9c'/%3E%3C/svg%3E"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://backend:8000/uploads/;
}
location /docs {
proxy_pass http://backend:8000/docs;
}
location /openapi.json {
proxy_pass http://backend:8000/openapi.json;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "dorm-repair-frontend",
"private": true,
"version": "0.0.0",
"description": "交互式透明化宿舍报修系统 - Vue 3 前端",
"packageManager": "pnpm@11.5.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"lint": "eslint src vite.config.ts eslint.config.js",
"preview": "vite preview",
"typecheck": "vue-tsc -b"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.17.0",
"element-plus": "^2.14.1",
"vue": "^3.5.35",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.9.2",
"@vitejs/plugin-vue": "^6.0.7",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.3"
}
}
+2388
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
minimumReleaseAgeExclude:
- '@types/node@25.9.2'
+4
View File
@@ -0,0 +1,4 @@
<template>
<RouterView />
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { SwitchButton, UserFilled } from '@element-plus/icons-vue'
import oucLogoUrl from '@/assets/ref/ouc-logo.png'
import { clearSession, getUser } from '@/lib/auth'
const props = defineProps<{
accent?: 'student' | 'admin'
}>()
const router = useRouter()
const user = computed(() => getUser())
const roleText = computed(() => (props.accent === 'admin' ? '后勤管理端' : '学生服务端'))
function goHome() {
router.push(props.accent === 'admin' ? '/admin/orders' : '/student/home')
}
function logout() {
clearSession()
router.push('/login')
}
</script>
<template>
<main class="portal-shell" :data-accent="accent ?? 'student'">
<header class="portal-topbar">
<div class="portal-topbar__inner">
<button class="portal-brand" type="button" @click="goHome">
<img :src="oucLogoUrl" alt="中国海洋大学" />
<span>后勤报修服务系统</span>
</button>
<div class="portal-topbar__tools">
<span class="portal-role-chip">{{ roleText }}</span>
<el-dropdown trigger="click">
<button class="portal-user" type="button">
<el-avatar :size="30">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span>{{ user?.display_name ?? '同学' }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goHome">返回首页</el-dropdown-item>
<el-dropdown-item divided @click="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</header>
<div class="portal-content">
<slot />
</div>
<footer class="portal-footer">
<div class="portal-footer__inner">
<img :src="oucLogoUrl" alt="中国海洋大学" />
<div>
<p>地址山东省青岛市古镇口军民融合创新示范区三沙路1299号</p>
<p>电话0532-60890000 · Copyright © 2026 中国海洋大学</p>
</div>
</div>
</footer>
</main>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
status: string
}>()
</script>
<template>
<span class="status-badge" :data-status="status">{{ status }}</span>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { OrderEvent } from '@/types'
defineProps<{
events: OrderEvent[]
}>()
</script>
<template>
<ol class="timeline">
<li v-for="event in events" :key="event.id" class="timeline__item">
<div class="timeline__dot" />
<div class="timeline__body">
<div class="timeline__row">
<strong>{{ event.title }}</strong>
<span>{{ event.created_at }}</span>
</div>
<p class="timeline__meta">{{ event.actor_name }} · {{ event.actor_role === 'admin' ? '管理员' : '学生' }}</p>
<p v-if="event.detail" class="timeline__detail">{{ event.detail }}</p>
</div>
</li>
</ol>
</template>
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
export default component
}
+31
View File
@@ -0,0 +1,31 @@
import axios from 'axios'
import { clearSession, getToken } from '@/lib/auth'
const baseURL = import.meta.env.VITE_API_BASE_URL as string || 'http://127.0.0.1:8000'
const api = axios.create({ baseURL })
api.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearSession()
}
return Promise.reject(error)
},
)
export function getUploadUrl(filePath: string): string {
return `${baseURL}/uploads/${filePath}`
}
export default api
+31
View File
@@ -0,0 +1,31 @@
import type { UserProfile } from '@/types'
const TOKEN_KEY = 'dorm-repair-token'
const USER_KEY = 'dorm-repair-user'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getUser(): UserProfile | null {
const raw = localStorage.getItem(USER_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as UserProfile
} catch {
clearSession()
return null
}
}
export function saveSession(token: string, user: UserProfile): void {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearSession(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
+51
View File
@@ -0,0 +1,51 @@
const ERROR_MESSAGES: Record<string, string> = {
missing_token: '缺少认证令牌',
invalid_token: '认证已过期,请重新登录',
invalid_credentials: '用户名或密码错误',
student_only: '此功能仅限学生使用',
admin_only: '此功能仅限管理员使用',
order_not_found: '工单不存在或已被删除',
session_not_found: '诊断会话已过期,请重新描述故障',
order_not_completed: '维修尚未完成,无法执行此操作',
order_not_ready_for_feedback: '当前状态不可评价',
order_cannot_cancel: '当前状态无法取消,仅已提交或待处理的工单可取消',
unsupported_file_type: '仅支持上传图片文件(jpg, png, gif, webp',
file_too_large: '文件大小超过限制(最大10MB',
order_not_in_rework: '当前工单不在返工申请状态',
validation_error: '请求参数有误',
internal_error: '服务器内部错误',
unauthorized: '未登录或登录已过期',
forbidden: '无权访问此资源',
}
const STATUS_MESSAGES: Record<number, string> = {
400: '请求有误',
401: '未登录或登录已过期',
403: '无权访问',
404: '请求的资源不存在',
422: '请求参数有误',
500: '服务器内部错误',
}
export function getErrorMessage(error: unknown): string {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { detail?: string; error_code?: string }; status?: number } }
const data = axiosError.response?.data
if (data?.error_code && ERROR_MESSAGES[data.error_code]) {
return ERROR_MESSAGES[data.error_code]
}
if (data?.detail) {
return data.detail
}
const status = axiosError.response?.status
if (status && STATUS_MESSAGES[status]) {
return STATUS_MESSAGES[status]
}
}
if (error instanceof Error) {
if (error.message === 'Network Error' || error.message.includes('ERR_NETWORK')) {
return '网络连接失败,请检查网络后重试'
}
}
return '操作失败,请稍后重试'
}
+2
View File
@@ -0,0 +1,2 @@
export const ACTIVE_STATUSES = new Set(['已提交', '待处理', '处理中', '待上门', '返工申请中'])
export const CLOSED_STATUSES = new Set(['已完成', '已确认', '已取消'])
+74
View File
@@ -0,0 +1,74 @@
import { createApp } from 'vue'
import {
ElAvatar,
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElIcon,
ElInput,
ElOption,
ElRate,
ElSelect,
ElSkeleton,
ElTable,
ElTableColumn,
ElTag,
ElTooltip,
ElUpload,
vLoading,
} from 'element-plus'
import 'element-plus/theme-chalk/base.css'
import 'element-plus/theme-chalk/el-avatar.css'
import 'element-plus/theme-chalk/el-button.css'
import 'element-plus/theme-chalk/el-card.css'
import 'element-plus/theme-chalk/el-dropdown.css'
import 'element-plus/theme-chalk/el-dropdown-menu.css'
import 'element-plus/theme-chalk/el-empty.css'
import 'element-plus/theme-chalk/el-icon.css'
import 'element-plus/theme-chalk/el-input.css'
import 'element-plus/theme-chalk/el-loading.css'
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-message-box.css'
import 'element-plus/theme-chalk/el-option.css'
import 'element-plus/theme-chalk/el-overlay.css'
import 'element-plus/theme-chalk/el-popper.css'
import 'element-plus/theme-chalk/el-rate.css'
import 'element-plus/theme-chalk/el-select.css'
import 'element-plus/theme-chalk/el-skeleton.css'
import 'element-plus/theme-chalk/el-table.css'
import 'element-plus/theme-chalk/el-table-column.css'
import 'element-plus/theme-chalk/el-tag.css'
import 'element-plus/theme-chalk/el-tooltip.css'
import 'element-plus/theme-chalk/el-upload.css'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app
.use(router)
.use(ElAvatar)
.use(ElButton)
.use(ElCard)
.use(ElDropdown)
.use(ElDropdownItem)
.use(ElDropdownMenu)
.use(ElEmpty)
.use(ElIcon)
.use(ElInput)
.use(ElOption)
.use(ElRate)
.use(ElSelect)
.use(ElSkeleton)
.use(ElTable)
.use(ElTableColumn)
.use(ElTag)
.use(ElTooltip)
.use(ElUpload)
.directive('loading', vLoading)
.mount('#app')
+76
View File
@@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/LoginView.vue'
import StudentHomeView from '@/views/StudentHomeView.vue'
import StudentReportView from '@/views/StudentReportView.vue'
import StudentOrdersView from '@/views/StudentOrdersView.vue'
import StudentOrderDetailView from '@/views/StudentOrderDetailView.vue'
import AdminOrdersView from '@/views/AdminOrdersView.vue'
import { getUser } from '@/lib/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/login',
},
{
path: '/login',
name: 'login',
component: LoginView,
},
{
path: '/student/home',
name: 'student-home',
component: StudentHomeView,
meta: { requiresAuth: true, role: 'student' },
},
{
path: '/student/report',
name: 'student-report',
component: StudentReportView,
meta: { requiresAuth: true, role: 'student' },
},
{
path: '/student/orders',
name: 'student-orders',
component: StudentOrdersView,
meta: { requiresAuth: true, role: 'student' },
},
{
path: '/student/orders/:id',
name: 'student-order-detail',
component: StudentOrderDetailView,
meta: { requiresAuth: true, role: 'student' },
},
{
path: '/admin/orders',
name: 'admin-orders',
component: AdminOrdersView,
meta: { requiresAuth: true, role: 'admin' },
},
],
})
router.beforeEach((to) => {
const user = getUser()
const requiresAuth = Boolean(to.meta.requiresAuth)
const expectedRole = to.meta.role as 'student' | 'admin' | undefined
if (requiresAuth && !user) {
return '/login'
}
if (to.path === '/login' && user) {
return user.role === 'admin' ? '/admin/orders' : '/student/home'
}
if (expectedRole && user?.role !== expectedRole) {
if (!user) {
return '/login'
}
return user.role === 'admin' ? '/admin/orders' : '/student/home'
}
return true
})
export default router
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
export type Role = 'student' | 'admin'
export interface UserProfile {
id: number
username: string
display_name: string
role: Role
}
export interface LoginResponse {
token: string
user: UserProfile
}
export interface DiagnosisQuestion {
id: string
prompt: string
}
export interface DiagnosisDraft {
category: string
urgency: '低' | '中' | '高' | '紧急'
summary: string
safety_risk: boolean
suggested_worker: string
notes: string[]
}
export interface DiagnosisResponse {
session_id: string
stage: 'questions' | 'draft'
initial_message: string
suggested_categories: string[]
questions: DiagnosisQuestion[]
draft: DiagnosisDraft | null
}
export interface SavedAddress {
id: number
campus: string
building: string
room: string
last_used_at: string
}
export interface OrderSummary {
id: number
order_no: string
campus: string
building: string
room: string
category: string
status: string
urgency: string
submission_time: string
expected_repair_time: string | null
assignee_name: string | null
}
export interface Attachment {
id: number
file_name: string
file_path: string
mime_type: string
created_at: string
}
export interface OrderEvent {
id: number
actor_role: string
actor_name: string
event_type: string
title: string
detail: string | null
from_status: string | null
to_status: string | null
created_at: string
}
export interface FeedbackData {
rating: number
comment: string
created_at: string
}
export interface OrderDetail {
id: number
order_no: string
campus: string
building: string
room: string
category: string
status: string
urgency: string
raw_description: string
structured_summary: string
allow_room_entry: boolean
expected_date: string | null
expected_time_segment: string | null
assignee_name: string | null
expected_arrival_at: string | null
admin_note: string | null
rework_reason: string | null
created_at: string
updated_at: string
attachments: Attachment[]
events: OrderEvent[]
feedback: FeedbackData | null
}
export interface StatItem {
category?: string
building?: string
count: number
}
export interface AdminStats {
category_distribution: StatItem[]
building_distribution: StatItem[]
avg_processing_hours: number
avg_rating: number
}
+396
View File
@@ -0,0 +1,396 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AppShell from '@/components/AppShell.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TimelineList from '@/components/TimelineList.vue'
import { getErrorMessage } from '@/lib/errors'
import { getUploadUrl } from '@/lib/api'
import api from '@/lib/api'
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
import type { AdminStats, OrderDetail, OrderSummary } from '@/types'
const loadingOrders = ref(false)
const saving = ref(false)
const errorMessage = ref('')
const orders = ref<OrderSummary[]>([])
const selectedOrder = ref<OrderDetail | null>(null)
const currentRow = ref<OrderSummary | null>(null)
const stats = ref<AdminStats | null>(null)
const filters = reactive({
status: '',
category: '',
urgency: '',
})
const updateForm = reactive({
status: '',
assignee_name: '',
expected_arrival_at: '',
admin_note: '',
})
const BAR_COLORS = ['#1559a8', '#12a88a', '#d97706', '#7c3aed', '#0891b2']
const categories = computed(() => Array.from(new Set(orders.value.map((order) => order.category))))
const dashboardStats = computed(() => {
const urgentStatuses = new Set(['高', '紧急'])
const activeCount = orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).length
const urgentCount = orders.value.filter((order) => urgentStatuses.has(order.urgency)).length
const completedCount = orders.value.filter((order) => CLOSED_STATUSES.has(order.status)).length
return [
{ label: '全部工单', value: orders.value.length, note: '当前筛选范围' },
{ label: '处理中', value: activeCount, note: '需要后勤继续跟进' },
{ label: '高优先级', value: urgentCount, note: '高与紧急工单' },
{ label: '已完成', value: completedCount, note: '待确认或已闭环' },
]
})
const maxCatCount = computed(() =>
Math.max(...(stats.value?.category_distribution.map((i) => i.count) ?? [0]), 1),
)
const maxBldCount = computed(() =>
Math.max(...(stats.value?.building_distribution.map((i) => i.count) ?? [0]), 1),
)
async function loadOrders() {
loadingOrders.value = true
errorMessage.value = ''
try {
const response = await api.get<OrderSummary[]>('/api/admin/orders', {
params: {
status: filters.status || undefined,
category: filters.category || undefined,
urgency: filters.urgency || undefined,
},
})
orders.value = response.data
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
loadingOrders.value = false
}
}
async function openOrder(orderId: number) {
errorMessage.value = ''
try {
const response = await api.get<OrderDetail>(`/api/admin/orders/${orderId}`)
selectedOrder.value = response.data
updateForm.status = response.data.status
updateForm.assignee_name = response.data.assignee_name ?? ''
updateForm.expected_arrival_at = response.data.expected_arrival_at ?? ''
updateForm.admin_note = response.data.admin_note ?? ''
} catch (err) {
errorMessage.value = getErrorMessage(err)
}
}
function handleTableKeydown(event: KeyboardEvent) {
if (currentRow.value && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
openOrder(currentRow.value.id)
}
}
async function updateOrder() {
if (!selectedOrder.value) {
return
}
saving.value = true
try {
await api.patch(`/api/admin/orders/${selectedOrder.value.id}`, {
status: updateForm.status,
assignee_name: updateForm.assignee_name || null,
expected_arrival_at: updateForm.expected_arrival_at || null,
admin_note: updateForm.admin_note || null,
})
ElMessage.success('工单更新已保存')
await loadOrders()
await openOrder(selectedOrder.value.id)
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
saving.value = false
}
}
async function loadStats() {
try {
const response = await api.get<AdminStats>('/api/admin/stats')
stats.value = response.data
} catch {
// stats are non-critical, ignore errors
}
}
onMounted(() => {
void loadOrders()
void loadStats()
})
</script>
<template>
<AppShell accent="admin">
<h1 class="page-title">工单处理</h1>
<div class="stack">
<div v-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
<section class="admin-command">
<div>
<h2>工单列表</h2>
</div>
</section>
<section class="admin-metrics">
<article v-for="stat in dashboardStats" :key="stat.label" class="admin-metric">
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<p>{{ stat.note }}</p>
</article>
</section>
<section v-if="stats" class="card card--subtle">
<div class="section-heading">
<div>
<h2>数据统计</h2>
</div>
</div>
<div class="stat-summary">
<div class="stat-summary__item">
<span>平均处理时长</span>
<strong>{{ stats.avg_processing_hours }}h</strong>
</div>
<div class="stat-summary__item">
<span>满意度均分</span>
<strong>{{ stats.avg_rating }} / 5</strong>
</div>
</div>
<div class="stat-section">
<div class="stat-card">
<h3>故障类别分布</h3>
<template v-if="stats.category_distribution.length">
<div v-for="(item, idx) in stats.category_distribution" :key="item.category!" class="stat-bar-row">
<span class="stat-bar-row__label">{{ item.category }}</span>
<div class="stat-bar-row__track">
<div
class="stat-bar-row__fill"
:style="{
width: (item.count / maxCatCount) * 100 + '%',
background: BAR_COLORS[idx % BAR_COLORS.length],
}"
/>
</div>
<span class="stat-bar-row__count">{{ item.count }}</span>
</div>
</template>
<div v-else class="stat-empty">暂无数据</div>
</div>
<div class="stat-card">
<h3>楼栋分布</h3>
<template v-if="stats.building_distribution.length">
<div v-for="(item, idx) in stats.building_distribution" :key="item.building!" class="stat-bar-row">
<span class="stat-bar-row__label">{{ item.building }}</span>
<div class="stat-bar-row__track">
<div
class="stat-bar-row__fill"
:style="{
width: (item.count / maxBldCount) * 100 + '%',
background: BAR_COLORS[(idx + 2) % BAR_COLORS.length],
}"
/>
</div>
<span class="stat-bar-row__count">{{ item.count }}</span>
</div>
</template>
<div v-else class="stat-empty">暂无数据</div>
</div>
</div>
</section>
<section class="card card--subtle">
<div class="section-heading">
<div>
<h2>筛选</h2>
</div>
<button class="button button--ghost" @click="loadOrders">刷新列表</button>
</div>
<div class="toolbar__filters">
<el-select v-model="filters.status" placeholder="全部状态" @change="loadOrders">
<el-option label="全部状态" value="" />
<el-option label="已提交" value="已提交" />
<el-option label="待处理" value="待处理" />
<el-option label="处理中" value="处理中" />
<el-option label="待上门" value="待上门" />
<el-option label="已完成" value="已完成" />
<el-option label="已确认" value="已确认" />
<el-option label="返工申请中" value="返工申请中" />
<el-option label="已取消" value="已取消" />
</el-select>
<el-select v-model="filters.category" placeholder="全部类别" @change="loadOrders">
<el-option label="全部类别" value="" />
<el-option v-for="category in categories" :key="category" :label="category" :value="category" />
</el-select>
<el-select v-model="filters.urgency" placeholder="全部紧急度" @change="loadOrders">
<el-option label="全部紧急度" value="" />
<el-option label="低" value="低" />
<el-option label="中" value="中" />
<el-option label="高" value="高" />
<el-option label="紧急" value="紧急" />
</el-select>
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>工单总览</h2>
</div>
<span class="section-heading__meta"> {{ orders.length }} 条记录</span>
</div>
<div class="table-scroll">
<el-table
v-loading="loadingOrders"
class="service-table"
:data="orders"
row-key="id"
empty-text="暂无工单"
highlight-current-row
tabindex="0"
@row-click="(row: OrderSummary) => openOrder(row.id)"
@current-change="(row: OrderSummary | null) => { currentRow = row }"
@keydown.enter.prevent="handleTableKeydown"
@keydown.space.prevent="handleTableKeydown"
>
<el-table-column prop="order_no" label="单号" min-width="138" />
<el-table-column prop="category" label="类别" min-width="104" />
<el-table-column label="状态" min-width="118">
<template #default="{ row }">
<StatusBadge :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="urgency" label="紧急度" min-width="90" />
<el-table-column label="宿舍" min-width="150">
<template #default="{ row }">{{ row.building }} / {{ row.room }}</template>
</el-table-column>
<el-table-column label="负责人" min-width="104">
<template #default="{ row }">{{ row.assignee_name || '待分配' }}</template>
</el-table-column>
</el-table>
</div>
</section>
<section v-if="selectedOrder" class="card">
<div class="detail-header">
<div>
<p class="muted">单号 {{ selectedOrder.order_no }}</p>
<h2>{{ selectedOrder.category }}</h2>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<StatusBadge :status="selectedOrder.status" />
<button class="button button--ghost" @click="selectedOrder = null">关闭详情</button>
</div>
</div>
<div class="service-summary">
<div>
<span>宿舍位置</span>
<strong>{{ selectedOrder.campus }} / {{ selectedOrder.building }} / {{ selectedOrder.room }}</strong>
</div>
<div>
<span>紧急程度</span>
<strong>{{ selectedOrder.urgency }}</strong>
</div>
<div>
<span>负责人</span>
<strong>{{ selectedOrder.assignee_name || '待分配' }}</strong>
</div>
<div>
<span>预计上门</span>
<strong>{{ selectedOrder.expected_arrival_at || '待安排' }}</strong>
</div>
<div>
<span>学生授权</span>
<strong>{{ selectedOrder.allow_room_entry ? '允许入室' : '需本人在场' }}</strong>
</div>
</div>
<div class="text-block">
<p class="muted">学生原始描述</p>
<p>{{ selectedOrder.raw_description }}</p>
</div>
<div class="text-block">
<p class="muted">整理后的报修摘要</p>
<p>{{ selectedOrder.structured_summary }}</p>
</div>
<div class="section-heading">
<div>
<h3>处理信息</h3>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>状态</span>
<el-select v-model="updateForm.status">
<el-option label="已提交" value="已提交" />
<el-option label="待处理" value="待处理" />
<el-option label="处理中" value="处理中" />
<el-option label="待上门" value="待上门" />
<el-option label="已完成" value="已完成" />
<el-option label="已确认" value="已确认" />
<el-option label="返工申请中" value="返工申请中" />
<el-option label="已取消" value="已取消" />
</el-select>
</label>
<label class="field">
<span>负责人</span>
<el-input v-model="updateForm.assignee_name" />
</label>
<label class="field">
<span>预计上门</span>
<el-input v-model="updateForm.expected_arrival_at" placeholder="今天 18:30" />
</label>
</div>
<label class="field">
<span>处理备注</span>
<el-input v-model="updateForm.admin_note" type="textarea" :rows="4" />
</label>
<el-button type="primary" :loading="saving" @click="updateOrder">保存工单更新</el-button>
<div v-if="selectedOrder.rework_reason" class="banner banner--warning">
学生返工说明:{{ selectedOrder.rework_reason }}
</div>
<div class="attachments" v-if="selectedOrder.attachments.length">
<p class="muted">学生上传的现场图片</p>
<div class="attachments__grid">
<img
v-for="attachment in selectedOrder.attachments"
:key="attachment.id"
:src="getUploadUrl(attachment.file_path)"
:alt="attachment.file_name"
/>
</div>
</div>
<div class="section-heading">
<div>
<h3>处理时间线</h3>
</div>
</div>
<TimelineList :events="selectedOrder.events" />
</section>
<div v-else-if="orders.length" class="empty-state">点击上方列表中的工单查看详情并填写处理记录</div>
</div>
</AppShell>
</template>
+312
View File
@@ -0,0 +1,312 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Lock, OfficeBuilding, User } from '@element-plus/icons-vue'
import api from '@/lib/api'
import { saveSession } from '@/lib/auth'
import { getErrorMessage } from '@/lib/errors'
import type { LoginResponse } from '@/types'
type LoginRole = 'student' | 'admin'
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
// 课程演示默认凭据,生产环境需移除
const form = reactive({
username: 'student01',
password: 'Student123',
})
const activeRole = ref<LoginRole>('student')
// 课程演示默认凭据,生产环境需移除
const roleProfiles: Record<LoginRole, { label: string; username: string; password: string; scope: string }> = {
student: {
label: '学生',
username: 'student01',
password: 'Student123',
scope: '报修处理',
},
admin: {
label: '管理员',
username: 'admin01',
password: 'Admin123',
scope: '工单调度',
},
}
async function submit() {
if (!form.username.trim() || !form.password.trim()) {
errorMessage.value = '请输入用户名和密码。'
return
}
loading.value = true
errorMessage.value = ''
try {
const response = await api.post<LoginResponse>('/api/auth/login', form)
saveSession(response.data.token, response.data.user)
router.push(response.data.user.role === 'admin' ? '/admin/orders' : '/student/home')
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
loading.value = false
}
}
function useRole(role: LoginRole) {
const profile = roleProfiles[role]
activeRole.value = role
form.username = profile.username
form.password = profile.password
}
</script>
<template>
<main class="login-page">
<form class="login-form" aria-labelledby="login-title" @submit.prevent="submit">
<div class="login-form__header">
<span class="login-form__eyebrow">统一身份认证</span>
<h1 id="login-title">登录系统</h1>
</div>
<div class="role-switch" role="group" aria-label="登录身份">
<button
v-for="(profile, role) in roleProfiles"
:key="role"
type="button"
:data-active="activeRole === role"
@click="useRole(role)"
>
<component :is="role === 'student' ? User : OfficeBuilding" />
<span>{{ profile.label }}</span>
<small>{{ profile.scope }}</small>
</button>
</div>
<div v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</div>
<label class="field">
<span>用户名</span>
<el-input
v-model="form.username"
:prefix-icon="User"
autocomplete="username"
clearable
placeholder="请输入账号"
size="large"
/>
</label>
<label class="field">
<span>密码</span>
<el-input
v-model="form.password"
:prefix-icon="Lock"
autocomplete="current-password"
placeholder="请输入密码"
show-password
size="large"
type="password"
/>
</label>
<el-button
class="login-submit"
native-type="submit"
type="primary"
:loading="loading"
>
进入系统
</el-button>
<div class="login-account-hint">
<span>当前身份</span>
<strong>{{ roleProfiles[activeRole].label }}</strong>
</div>
</form>
</main>
</template>
<style scoped>
.login-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 32px 16px;
background: #eef4fb;
}
.login-form {
display: grid;
gap: 16px;
width: min(100%, 560px);
padding: clamp(24px, 5vw, 44px);
border: 1px solid #d9e2ec;
border-left: 5px solid #004098;
border-radius: 14px;
background: #fff;
box-shadow: 0 18px 42px rgba(21, 89, 168, 0.1);
}
.login-form__header {
display: grid;
gap: 6px;
padding-bottom: 4px;
}
.login-form__eyebrow {
color: #004098;
font-size: 18px;
font-weight: 700;
}
.login-form__header h1 {
margin: 0;
color: #1f2937;
font-size: clamp(36px, 6vw, 52px);
line-height: 1.1;
}
.role-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.role-switch button {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 2px 8px;
align-items: center;
min-height: 64px;
padding: 10px;
border: 1px solid #d9e2ec;
border-radius: 8px;
background: #fff;
color: #475569;
text-align: left;
cursor: pointer;
transition:
border-color 160ms ease,
background-color 160ms ease,
color 160ms ease,
transform 120ms ease;
}
.role-switch button:hover {
border-color: #a9c8ed;
background: #fbfdff;
}
.role-switch button:active,
.login-submit:active {
transform: translateY(1px);
}
.role-switch button[data-active='true'] {
border-color: #004098;
background: #eaf3ff;
color: #004098;
}
.role-switch svg {
grid-row: span 2;
width: 20px;
height: 20px;
}
.role-switch span {
min-width: 0;
font-size: 14px;
font-weight: 700;
}
.role-switch small {
min-width: 0;
color: #64748b;
font-size: 12px;
}
.login-error {
padding: 12px 14px;
border: 1px solid #f8cbd0;
background: #fef1f2;
color: #be2f3c;
font-size: 14px;
font-weight: 600;
}
.login-submit.el-button {
width: 100%;
min-height: 44px;
}
.login-submit.el-button--primary {
--el-button-bg-color: #004098;
--el-button-border-color: #004098;
--el-button-hover-bg-color: #0b3d78;
--el-button-hover-border-color: #0b3d78;
--el-button-active-bg-color: #0b3d78;
--el-button-active-border-color: #0b3d78;
}
.login-form :deep(.el-input__wrapper) {
min-height: 44px;
border-radius: 8px;
box-shadow: 0 0 0 1px #d9e2ec inset;
}
.login-form :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px #7ba4e8 inset,
0 0 0 3px rgba(27, 79, 156, 0.1);
}
.login-form :deep(.el-input__prefix) {
color: #004098;
}
.login-account-hint {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding-top: 2px;
color: #64748b;
font-size: 13px;
}
.login-account-hint span {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.login-account-hint strong {
min-width: 0;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
}
.field {
gap: 7px;
}
@media (max-width: 640px) {
.login-page {
align-items: start;
padding-top: 34px;
}
.login-form {
gap: 14px;
}
.role-switch {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
CircleCheck,
Finished,
House,
Warning,
} from '@element-plus/icons-vue'
import AppShell from '@/components/AppShell.vue'
import { getUser } from '@/lib/auth'
import api from '@/lib/api'
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
import type { OrderSummary } from '@/types'
const router = useRouter()
const user = computed(() => getUser())
const orders = ref<OrderSummary[]>([])
const loadingOrders = ref(false)
const totalOrders = computed(() => orders.value.length)
const isEmpty = computed(() => totalOrders.value === 0 && !loadingOrders.value)
const activeOrders = computed(() => orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).length)
const closedOrders = computed(() => orders.value.filter((order) => CLOSED_STATUSES.has(order.status)).length)
const currentOrders = computed(() => orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).slice(0, 3))
const recentOrders = computed(() => orders.value.slice(0, 4))
const serviceStats = computed(() => [
{ label: '累计工单', value: totalOrders.value, icon: Finished },
{ label: '处理中', value: activeOrders.value, icon: Warning },
{ label: '已办结', value: closedOrders.value, icon: CircleCheck },
])
async function loadOrders() {
loadingOrders.value = true
try {
const response = await api.get<OrderSummary[]>('/api/student/orders')
orders.value = response.data
} catch {
orders.value = []
} finally {
loadingOrders.value = false
}
}
onMounted(() => {
void loadOrders()
})
</script>
<template>
<AppShell accent="student">
<h1 class="page-title">后勤报修服务</h1>
<p class="page-subtitle">提交宿舍设施报修查看维修进度与处理结果</p>
<div class="portal-home">
<section class="portal-overview-strip">
<el-card class="portal-profile-card" shadow="never">
<div class="portal-profile-card__main">
<el-avatar :size="54">
<el-icon><House /></el-icon>
</el-avatar>
<div class="portal-profile-card__text">
<span>西海岸校区后勤报修</span>
<h1>{{ user?.display_name ?? '同学' }}需要报修从这里开始</h1>
<p>宿舍设施故障先提交报修已提交的问题在"我的工单"查看进度</p>
<div class="portal-profile-card__actions">
<button class="button" type="button" @click="router.push('/student/report')">提交报修</button>
<button class="text-button" type="button" @click="router.push('/student/orders')">查看我的工单</button>
</div>
</div>
</div>
</el-card>
</section>
<section v-if="isEmpty" class="welcome-card card">
<div class="welcome-card__content">
<h2>还没有报修记录</h2>
<p>遇到宿舍设施问题从提交第一份工单开始</p>
<div class="welcome-card__actions">
<button class="button" type="button" @click="router.push('/student/report')">提交报修</button>
</div>
</div>
</section>
<template v-else>
<section class="portal-stats-row" aria-label="报修统计">
<el-card
v-for="stat in serviceStats"
:key="stat.label"
class="portal-stat-card"
shadow="never"
>
<div class="portal-stat-card__icon">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
</el-card>
</section>
<section class="portal-workspace">
<el-card class="portal-orders-card" shadow="never">
<template #header>
<div class="portal-card-header">
<div>
<span>当前进度</span>
<h2>需要关注的报修</h2>
</div>
<el-button type="primary" plain @click="router.push('/student/orders')">全部工单</el-button>
</div>
</template>
<el-skeleton v-if="loadingOrders" :rows="4" animated />
<el-empty v-else-if="currentOrders.length === 0" description="当前没有未办结工单" />
<el-table
v-else
class="portal-order-table"
:data="currentOrders"
:show-header="false"
@row-click="(order: OrderSummary) => router.push(`/student/orders/${order.id}`)"
>
<el-table-column min-width="220">
<template #default="{ row }">
<div class="portal-order-main">
<strong>{{ row.category }}</strong>
<span>{{ row.building }} {{ row.room }} · {{ row.order_no }}</span>
</div>
</template>
</el-table-column>
<el-table-column min-width="128">
<template #default="{ row }">
<el-tag effect="light" type="primary">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column min-width="220">
<template #default="{ row }">
<span class="portal-muted">{{ row.assignee_name || '等待分配' }} / {{ row.expected_repair_time || '时间待定' }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="portal-recent-card" shadow="never">
<template #header>
<div class="portal-card-header">
<div>
<span>最近记录</span>
<h2>报修记录</h2>
</div>
</div>
</template>
<el-empty v-if="recentOrders.length === 0" description="暂无报修记录" />
<div v-else class="portal-recent-list">
<button
v-for="order in recentOrders"
:key="order.id"
type="button"
@click="router.push(`/student/orders/${order.id}`)"
>
<span>{{ order.category }}</span>
<el-tag size="small" type="info">{{ order.status }}</el-tag>
</button>
</div>
</el-card>
</section>
</template>
</div>
</AppShell>
</template>
@@ -0,0 +1,368 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import emptyStateUrl from '@/assets/ref/empty-state.png'
import AppShell from '@/components/AppShell.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TimelineList from '@/components/TimelineList.vue'
import api from '@/lib/api'
import { getErrorMessage } from '@/lib/errors'
import { getUploadUrl } from '@/lib/api'
import type { OrderDetail } from '@/types'
const route = useRoute()
const router = useRouter()
const order = ref<OrderDetail | null>(null)
const loadingOrder = ref(false)
const confirming = ref(false)
const submittingFeedback = ref(false)
const submittingRework = ref(false)
const cancelling = ref(false)
const errorMessage = ref('')
const feedbackComment = ref('')
const feedbackRating = ref(5)
const reworkReason = ref('')
const canFinish = computed(() => order.value?.status === '已完成')
const canCancel = computed(() => order.value?.status === '已提交' || order.value?.status === '待处理')
const progressSteps = ['已提交', '待处理', '待上门', '处理中', '已完成', '已确认']
const statusGuides: Record<string, { explanation: string; nextStep: string; studentAction: string; responsibility: string; needsAction: boolean }> = {
已提交: {
explanation: '已提交,等待后勤受理。',
nextStep: '受理后将补充负责人和预计上门时间。',
studentAction: '请保持联系方式畅通。',
responsibility: '等待后勤',
needsAction: false,
},
待处理: {
explanation: '后勤已受理,正在安排处理。',
nextStep: '确认负责人后进入待上门或处理中。',
studentAction: '请留意预计上门时间。',
responsibility: '等待后勤',
needsAction: false,
},
待上门: {
explanation: '维修已安排,等待上门。',
nextStep: '上门后更新处理状态。',
studentAction: '请按授权方式配合维修。',
responsibility: '等待上门',
needsAction: false,
},
处理中: {
explanation: '维修人员正在处理。',
nextStep: '处理结束后会更新为已完成。',
studentAction: '请等待处理结果。',
responsibility: '维修中',
needsAction: false,
},
已完成: {
explanation: '后勤已标记完成,等待确认。',
nextStep: '确认后工单关闭。',
studentAction: '请检查结果,可确认、评价或返工。',
responsibility: '需要您确认',
needsAction: true,
},
已确认: {
explanation: '已确认完成。',
nextStep: '工单已关闭。',
studentAction: '同类问题再次发生时请重新报修。',
responsibility: '已完成',
needsAction: false,
},
返工申请中: {
explanation: '返工申请已提交。',
nextStep: '后勤将重新安排处理。',
studentAction: '请等待反馈。',
responsibility: '等待后勤',
needsAction: false,
},
}
const activeStepIndex = computed(() => {
if (!order.value) {
return -1
}
if (order.value.status === '返工申请中') {
return 4
}
return progressSteps.indexOf(order.value.status)
})
const progressTooltips: Record<string, string> = {
已提交: '工单已成功创建,等待后勤受理',
待处理: '后勤已受理,正在安排处理人员',
待上门: '维修人员已确认,准备上门',
处理中: '维修人员正在现场处理',
已完成: '维修已处理完成,等待您确认',
已确认: '您已确认完成,工单已关闭',
}
const currentStatusGuide = computed(() => {
if (!order.value) {
return null
}
return statusGuides[order.value.status] ?? {
explanation: '状态已更新。',
nextStep: '请查看处理时间线。',
studentAction: '请关注后续记录。',
responsibility: '处理中',
needsAction: false,
}
})
async function loadOrder() {
loadingOrder.value = true
try {
const response = await api.get<OrderDetail>(`/api/student/orders/${route.params.id}`)
order.value = response.data
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
loadingOrder.value = false
}
}
async function confirmComplete() {
confirming.value = true
try {
await api.post(`/api/student/orders/${route.params.id}/confirm`)
ElMessage.success('已确认维修完成')
await loadOrder()
} catch (err) {
ElMessage.error(getErrorMessage(err))
} finally {
confirming.value = false
}
}
async function submitFeedback() {
if (!canFinish.value) {
ElMessage.warning('维修完成后再提交评价')
return
}
if (!feedbackComment.value.trim()) {
ElMessage.warning('请填写评价内容')
return
}
submittingFeedback.value = true
try {
await api.post(`/api/student/orders/${route.params.id}/feedback`, {
rating: feedbackRating.value,
comment: feedbackComment.value,
})
feedbackComment.value = ''
ElMessage.success('评价已提交')
await loadOrder()
} catch (err) {
ElMessage.error(getErrorMessage(err))
} finally {
submittingFeedback.value = false
}
}
async function submitRework() {
if (!canFinish.value) {
ElMessage.warning('维修完成后才能申请返工')
return
}
if (!reworkReason.value.trim()) {
ElMessage.warning('请填写返工原因')
return
}
submittingRework.value = true
try {
await api.post(`/api/student/orders/${route.params.id}/rework`, {
reason: reworkReason.value,
})
reworkReason.value = ''
ElMessage.warning('返工申请已提交')
await loadOrder()
} catch (err) {
ElMessage.error(getErrorMessage(err))
} finally {
submittingRework.value = false
}
}
async function cancelOrder() {
try {
await ElMessageBox.confirm('确定要取消该工单吗?取消后无法恢复。', '确认取消', {
confirmButtonText: '确定取消',
cancelButtonText: '返回',
type: 'warning',
})
} catch {
return
}
cancelling.value = true
try {
await api.post(`/api/student/orders/${route.params.id}/cancel`)
ElMessage.success('工单已取消')
router.push('/student/orders')
} catch (err) {
ElMessage.error(getErrorMessage(err))
} finally {
cancelling.value = false
}
}
onMounted(() => {
void loadOrder()
})
</script>
<template>
<AppShell accent="student">
<h1 class="page-title">工单详情</h1>
<div class="stack">
<div v-if="loadingOrder" class="card empty-state">正在加载工单详情...</div>
<div v-else-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
<template v-else-if="order">
<div class="local-actions">
<button class="text-button" type="button" @click="router.push('/student/orders')">返回工单列表</button>
<button class="text-button" type="button" @click="router.push('/student/home')">返回报修首页</button>
</div>
<div class="detail-header">
<div>
<p class="muted">单号 {{ order.order_no }}</p>
<h2>{{ order.category }}</h2>
</div>
<div class="detail-header__right">
<StatusBadge :status="order.status" />
<el-button v-if="canCancel" type="danger" :loading="cancelling" @click="cancelOrder">取消工单</el-button>
</div>
</div>
<section class="card service-progress-card">
<div class="section-heading">
<div>
<h3>当前处理进度</h3>
</div>
<span class="section-heading__meta responsibility-tag" :data-needs-action="currentStatusGuide?.needsAction">
{{ currentStatusGuide?.responsibility }}
</span>
</div>
<div class="progress-track" :data-rework="order.status === '返工申请中'">
<el-tooltip
v-for="(step, index) in progressSteps"
:key="step"
:content="progressTooltips[step] || step"
placement="top"
>
<div
class="progress-track__step"
:data-active="index <= activeStepIndex"
:data-current="index === activeStepIndex"
:aria-current="index === activeStepIndex ? 'step' : undefined"
>
<span>{{ index + 1 }}</span>
<strong>{{ step }}</strong>
</div>
</el-tooltip>
</div>
<div v-if="order.status === '返工申请中'" class="banner banner--warning">
该工单已进入返工流程后勤将根据返工原因重新处理
</div>
<div v-if="currentStatusGuide" class="status-guide">
<article>
<span>当前说明</span>
<p>{{ currentStatusGuide.explanation }}</p>
</article>
<article>
<span>下一步</span>
<p>{{ currentStatusGuide.nextStep }}</p>
</article>
<article v-if="!currentStatusGuide.needsAction">
<span>你可以做</span>
<p>{{ currentStatusGuide.studentAction }}</p>
</article>
</div>
<div v-if="currentStatusGuide?.needsAction" class="card card--subtle">
<h3>确认维修结果</h3>
<div class="button-row" style="margin-bottom: 12px;">
<el-button type="primary" :loading="confirming" @click="confirmComplete">确认完成</el-button>
</div>
<label class="field">
<span>评分</span>
<el-rate v-model="feedbackRating" />
</label>
<label class="field">
<span>评价内容</span>
<el-input v-model="feedbackComment" type="textarea" :rows="2" placeholder="补充维修体验和结果" />
</label>
<div class="button-row" style="margin-top: 10px;">
<el-button plain type="primary" :loading="submittingFeedback" @click="submitFeedback">提交评价</el-button>
<el-button type="danger" plain :loading="submittingRework" @click="submitRework">申请返工</el-button>
</div>
<p v-if="order.feedback" class="muted" style="margin-top: 8px;">已提交评价{{ order.feedback.rating }} · {{ order.feedback.comment }}</p>
</div>
</section>
<section class="detail-grid">
<article class="card">
<h3>基本信息</h3>
<div class="service-summary">
<div>
<span>宿舍地址</span>
<strong>{{ order.campus }} / {{ order.building }} / {{ order.room }}</strong>
</div>
<div>
<span>紧急程度</span>
<strong>{{ order.urgency }}</strong>
</div>
<div>
<span>责任人</span>
<strong>{{ order.assignee_name || '待分配' }}</strong>
</div>
<div>
<span>预计上门</span>
<strong>{{ order.expected_arrival_at || '待安排' }}</strong>
</div>
<div>
<span>入室授权</span>
<strong>{{ order.allow_room_entry ? '允许无人入室' : '需本人在场' }}</strong>
</div>
</div>
<div class="text-block">
<p class="muted">报修原始描述</p>
<p>{{ order.raw_description }}</p>
</div>
<div class="text-block">
<p class="muted">报修摘要</p>
<p>{{ order.structured_summary }}</p>
</div>
<div v-if="order.admin_note" class="text-block">
<p class="muted">处理备注</p>
<p>{{ order.admin_note }}</p>
</div>
<div v-if="order.attachments.length" class="attachments">
<p class="muted">附件图片</p>
<div class="attachments__grid">
<img
v-for="attachment in order.attachments"
:key="attachment.id"
:src="getUploadUrl(attachment.file_path)"
:alt="attachment.file_name"
/>
</div>
</div>
</article>
<article class="card">
<h3>处理进度</h3>
<TimelineList v-if="order.events.length" :events="order.events" />
<div v-else class="empty-illustration">
<img :src="emptyStateUrl" alt="" />
<span>暂无处理记录</span>
</div>
</article>
</section>
<div v-if="order.rework_reason && order.status === '返工申请中'" class="banner banner--warning" style="margin-top: 8px;">
当前返工原因{{ order.rework_reason }}
</div>
</template>
</div>
</AppShell>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '@/components/AppShell.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import api from '@/lib/api'
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
import type { OrderSummary } from '@/types'
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
}
const router = useRouter()
const orders = ref<OrderSummary[]>([])
const loading = ref(false)
const activeOrders = computed(() => orders.value.filter(o => ACTIVE_STATUSES.has(o.status)))
const closedOrders = computed(() => orders.value.filter(o => CLOSED_STATUSES.has(o.status)))
async function loadOrders() {
loading.value = true
try {
const response = await api.get<OrderSummary[]>('/api/student/orders')
orders.value = response.data
} catch {
orders.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
void loadOrders()
})
</script>
<template>
<AppShell accent="student">
<h1 class="page-title">我的工单</h1>
<p class="page-subtitle">查看本人提交的宿舍报修记录与当前处理进度</p>
<div class="stack">
<div class="section-heading">
<div>
<h2>工单记录</h2>
</div>
<div class="button-row">
<button class="text-button" type="button" @click="router.push('/student/home')">返回报修首页</button>
<button class="button" @click="router.push('/student/report')">新建报修</button>
</div>
</div>
<article class="card" v-if="loading">
<div class="empty-state">读取中...</div>
</article>
<article class="card" v-else-if="orders.length === 0">
<div class="empty-state">暂无工单</div>
</article>
<template v-else>
<article v-if="activeOrders.length" class="card card--list">
<h3 style="margin: 0 0 12px;">进行中 <span class="muted" style="font-weight: 400;">{{ activeOrders.length }}</span></h3>
<div class="order-list">
<button
v-for="order in activeOrders"
:key="order.id"
class="order-card"
type="button"
@click="router.push(`/student/orders/${order.id}`)"
>
<div class="order-card__row">
<strong>{{ order.category }}</strong>
<StatusBadge :status="order.status" />
</div>
<p>{{ order.campus }} / {{ order.building }} / {{ order.room }}</p>
<div class="order-card__meta">
<span><b>单号</b>{{ order.order_no }}</span>
<span><b>紧急度</b>{{ order.urgency }}</span>
<span><b>{{ relativeTime(order.submission_time) }}</b></span>
</div>
<p v-if="order.expected_repair_time" class="order-card__hint">
预计处理{{ order.expected_repair_time }}
</p>
</button>
</div>
</article>
<article v-if="closedOrders.length" class="card card--list">
<h3 style="margin: 0 0 12px;">已办结 <span class="muted" style="font-weight: 400;">{{ closedOrders.length }}</span></h3>
<div class="order-list">
<button
v-for="order in closedOrders"
:key="order.id"
class="order-card"
type="button"
@click="router.push(`/student/orders/${order.id}`)"
>
<div class="order-card__row">
<strong>{{ order.category }}</strong>
<StatusBadge :status="order.status" />
</div>
<p>{{ order.campus }} / {{ order.building }} / {{ order.room }}</p>
<div class="order-card__meta">
<span><b>单号</b>{{ order.order_no }}</span>
<span><b>紧急度</b>{{ order.urgency }}</span>
<span><b>{{ relativeTime(order.submission_time) }}</b></span>
</div>
<p v-if="order.expected_repair_time" class="order-card__hint">
预计处理{{ order.expected_repair_time }}
</p>
</button>
</div>
</article>
</template>
</div>
</AppShell>
</template>
+705
View File
@@ -0,0 +1,705 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { UploadFile, UploadRawFile } from 'element-plus'
import { ElMessage } from 'element-plus'
import AppShell from '@/components/AppShell.vue'
import api from '@/lib/api'
import { getUser } from '@/lib/auth'
import { getErrorMessage } from '@/lib/errors'
import type { DiagnosisResponse, SavedAddress } from '@/types'
type FlowStep = 'input' | 'questions' | 'review'
type AddressOption = SavedAddress & {
contact_name?: string
phone?: string
source?: 'saved' | 'local'
}
type RepairForm = {
rawDescription: string
campus: string
building: string
room: string
category: string
structuredSummary: string
urgency: '低' | '中' | '高' | '紧急'
expectedDate: string
expectedTimeSegment: string
allowRoomEntry: boolean | null
}
interface RepairItem {
label: string
chargeType: string
urgency: '低' | '中' | '高' | '紧急'
}
interface RepairGroup {
label: string
items: RepairItem[]
}
const router = useRouter()
const user = computed(() => getUser())
const diagnosing = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const flowStep = ref<FlowStep>('input')
const diagnosis = ref<DiagnosisResponse | null>(null)
const addresses = ref<SavedAddress[]>([])
const localAddresses = ref<AddressOption[]>([])
const selectedFiles = ref<UploadRawFile[]>([])
const faultCount = ref(1)
const showAddressForm = ref(false)
const selectedAddressId = ref<number | null>(null)
const sidebarCollapsed = ref(false)
const questionAnswers = reactive<Record<string, string>>({})
const repairGroups: RepairGroup[] = [
{
label: '给排水',
items: [
{ label: '水龙头类', chargeType: '无偿', urgency: '中' },
{ label: '下水疏通类', chargeType: '无偿', urgency: '高' },
{ label: '洗手池类', chargeType: '无偿', urgency: '中' },
{ label: '阀门开关类', chargeType: '无偿', urgency: '中' },
],
},
{
label: '电路照明',
items: [
{ label: '照明故障', chargeType: '无偿', urgency: '中' },
{ label: '插座异常', chargeType: '现场确认', urgency: '高' },
{ label: '断电跳闸', chargeType: '无偿', urgency: '紧急' },
{ label: '空开面板', chargeType: '现场确认', urgency: '高' },
],
},
{
label: '门窗锁具',
items: [
{ label: '门锁钥匙', chargeType: '现场确认', urgency: '中' },
{ label: '窗户五金', chargeType: '无偿', urgency: '中' },
{ label: '柜门合页', chargeType: '现场确认', urgency: '低' },
{ label: '门体变形', chargeType: '现场确认', urgency: '中' },
],
},
{
label: '家具设施',
items: [
{ label: '床铺护栏', chargeType: '现场确认', urgency: '中' },
{ label: '桌椅维修', chargeType: '现场确认', urgency: '低' },
{ label: '衣柜抽屉', chargeType: '现场确认', urgency: '低' },
{ label: '其他设施', chargeType: '现场确认', urgency: '低' },
],
},
{
label: '空调设备',
items: [
{ label: '制冷制热异常', chargeType: '现场确认', urgency: '中' },
{ label: '遥控器面板', chargeType: '现场确认', urgency: '低' },
{ label: '漏水异响', chargeType: '现场确认', urgency: '中' },
],
},
{
label: '网络弱电',
items: [
{ label: '网口异常', chargeType: '无偿', urgency: '中' },
{ label: '弱电箱', chargeType: '无偿', urgency: '中' },
{ label: '门禁设备', chargeType: '无偿', urgency: '高' },
],
},
{
label: '其他',
items: [
{ label: '其他设施', chargeType: '现场确认', urgency: '低' },
],
},
]
const repairSelection = reactive({
group: '给排水',
item: '水龙头类',
})
const form = reactive<RepairForm>({
rawDescription: '',
campus: '',
building: '',
room: '',
category: '',
structuredSummary: '',
urgency: '中',
expectedDate: '',
expectedTimeSegment: '',
allowRoomEntry: null,
})
const addressDraft = reactive({
campus: '西海岸校区',
building: '',
room: '',
contact_name: '',
phone: '',
makeDefault: false,
})
const selectedRepairGroup = computed(() => repairGroups.find((group) => group.label === repairSelection.group) ?? repairGroups[0])
const selectedRepairItem = computed(
() =>
selectedRepairGroup.value.items.find((item) => item.label === repairSelection.item) ??
selectedRepairGroup.value.items[0],
)
const selectedRepairLabel = computed(() => `${selectedRepairGroup.value.label} / ${selectedRepairItem.value.label}`)
const chargeType = computed(() => selectedRepairItem.value.chargeType)
const addressOptions = computed<AddressOption[]>(() => [
...localAddresses.value,
...addresses.value.map((address) => ({ ...address, source: 'saved' as const })),
])
const availableDates = computed(() => {
const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
return Array.from({ length: 5 }, (_, index) => {
const date = new Date()
date.setDate(date.getDate() + index)
return formatter.format(date)
})
})
const timeSegments = ['不指定', '09:00-11:00', '14:00-16:00', '18:00-20:00']
const categoryAliases: Record<string, string> = {
网络设备: '网络弱电',
}
const completionItems = computed(() => [
{ label: '描述', done: Boolean(form.rawDescription.trim()) },
{ label: '地址', done: Boolean(form.campus && form.building && form.room) },
{ label: '项目', done: Boolean(form.category) },
{ label: '入室', done: form.allowRoomEntry !== null },
])
const assistantDraftItems = computed(() => {
const draft = diagnosis.value?.draft
if (!draft) {
return []
}
return [
{ label: '维修项目', value: selectedRepairLabel.value },
{ label: '紧急程度', value: form.urgency },
{ label: '负责工种', value: draft.suggested_worker },
]
})
const canSubmitQuestions = computed(() =>
(diagnosis.value?.questions ?? []).every((question) => questionAnswers[question.id]?.trim()),
)
const sectionStatus = computed(() => ({
address: form.campus.trim() && form.building.trim() && form.room.trim(),
category: Boolean(form.category),
time: form.allowRoomEntry !== null,
}))
const formReady = computed(() => form.rawDescription.trim() && form.allowRoomEntry !== null)
async function loadAddresses() {
try {
const response = await api.get<SavedAddress[]>('/api/student/addresses')
addresses.value = response.data
if (selectedAddressId.value === null && response.data[0]) {
useAddress(response.data[0])
}
if (!response.data.length) {
showAddressForm.value = true
}
} catch {
console.warn('Failed to load saved addresses, using manual entry')
showAddressForm.value = true
}
}
async function startDiagnosis() {
if (!form.rawDescription.trim()) {
errorMessage.value = '请先描述问题。'
return
}
diagnosing.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await api.post<DiagnosisResponse>('/api/student/diagnosis/start', {
message: form.rawDescription,
})
diagnosis.value = response.data
flowStep.value = response.data.stage === 'questions' ? 'questions' : 'review'
if (response.data.draft) {
syncRepairSelection(response.data.draft.category)
form.urgency = response.data.draft.urgency
form.structuredSummary = response.data.draft.summary
}
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
diagnosing.value = false
}
}
async function submitAnswers() {
if (!diagnosis.value || !canSubmitQuestions.value) {
return
}
diagnosing.value = true
errorMessage.value = ''
try {
const response = await api.post<DiagnosisResponse>('/api/student/diagnosis/answer', {
session_id: diagnosis.value.session_id,
answers: diagnosis.value.questions.map((question) => ({
question_id: question.id,
answer: questionAnswers[question.id],
})),
})
diagnosis.value = response.data
syncRepairSelection(response.data.draft?.category ?? '')
form.urgency = response.data.draft?.urgency ?? '中'
form.structuredSummary = response.data.draft?.summary ?? ''
flowStep.value = 'review'
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
diagnosing.value = false
}
}
function useAddress(address: SavedAddress) {
selectedAddressId.value = address.id
form.campus = address.campus
form.building = address.building
form.room = address.room
}
function syncRepairSelection(category: string) {
const [groupName, itemName] = category.split('/').map((part) => part.trim())
const normalizedGroupName = categoryAliases[groupName] ?? groupName
const normalizedCategory = categoryAliases[category] ?? category
const group = repairGroups.find((entry) => entry.label === normalizedGroupName || entry.label === normalizedCategory)
if (!group) {
repairSelection.group = '家具设施'
repairSelection.item = '其他设施'
form.category = selectedRepairLabel.value
return
}
repairSelection.group = group.label
repairSelection.item = group.items.find((item) => item.label === itemName)?.label ?? group.items[0].label
form.category = selectedRepairLabel.value
}
function onRepairGroupChange() {
repairSelection.item = selectedRepairGroup.value.items[0].label
updateRepairMeta()
}
function updateRepairMeta() {
form.category = selectedRepairLabel.value
form.urgency = selectedRepairItem.value.urgency
}
function applyAddressDraft() {
if (!addressDraft.building.trim() || !addressDraft.room.trim()) {
ElMessage.warning('请填写楼栋和房间')
return
}
const address: AddressOption = {
id: Date.now() * -1,
campus: addressDraft.campus.trim() || '西海岸校区',
building: addressDraft.building.trim(),
room: addressDraft.room.trim(),
contact_name: addressDraft.contact_name.trim() || user.value?.display_name || '本人',
phone: addressDraft.phone.trim(),
last_used_at: new Date().toISOString(),
source: 'local',
}
localAddresses.value = addressDraft.makeDefault ? [address, ...localAddresses.value] : [...localAddresses.value, address]
useAddress(address)
showAddressForm.value = false
addressDraft.building = ''
addressDraft.room = ''
addressDraft.contact_name = ''
addressDraft.phone = ''
addressDraft.makeDefault = false
}
async function deleteAddress(address: AddressOption) {
try {
if (address.source === 'saved') {
await api.delete(`/api/student/addresses/${address.id}`)
addresses.value = addresses.value.filter(a => a.id !== address.id)
} else {
localAddresses.value = localAddresses.value.filter(a => a.id !== address.id)
}
if (selectedAddressId.value === address.id) {
selectedAddressId.value = null
form.campus = ''
form.building = ''
form.room = ''
}
if (!addressOptions.value.length) {
showAddressForm.value = true
}
ElMessage.success('地址已删除')
} catch {
ElMessage.warning('删除地址失败')
}
}
function changeFaultCount(delta: number) {
faultCount.value = Math.max(1, Math.min(20, faultCount.value + delta))
}
function scrollToStep(index: number) {
document.querySelector(`[data-step='step${index}']`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function onUploadChange(_: UploadFile, uploadFiles: UploadFile[]) {
const hasUnsupportedFile = uploadFiles.some((file) => file.raw && !file.raw.type.startsWith('image/'))
if (hasUnsupportedFile) {
ElMessage.warning('仅支持上传现场图片')
}
selectedFiles.value = uploadFiles
.slice(0, 3)
.map((file) => file.raw)
.filter((file): file is UploadRawFile => Boolean(file && file.type.startsWith('image/')))
}
function addressBadge(address: AddressOption) {
if (address.source === 'local') {
return '本次新增'
}
return selectedAddressId.value === address.id ? '当前地址' : '历史地址'
}
async function submitOrder() {
const summary = form.structuredSummary.trim() || form.rawDescription.trim()
const rawDescription = `${form.rawDescription.trim() || summary}(数量:${faultCount.value}`
form.category = selectedRepairLabel.value
if (!form.campus.trim() || !form.building.trim() || !form.room.trim()) {
errorMessage.value = '请填写完整维修地址。'
return
}
if (!form.category.trim() || !summary) {
errorMessage.value = '请填写维修项目和故障描述。'
return
}
if (form.allowRoomEntry === null) {
errorMessage.value = '请选择是否允许无人时维修。'
return
}
submitting.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const createResponse = await api.post<{ order_id: number }>('/api/student/orders', {
campus: form.campus,
building: form.building,
room: form.room,
category: selectedRepairLabel.value,
raw_description: rawDescription,
structured_summary: summary,
urgency: form.urgency,
expected_date: form.expectedDate || null,
expected_time_segment: form.expectedTimeSegment || null,
diagnosis_session_id: diagnosis.value?.session_id ?? null,
allow_room_entry: form.allowRoomEntry,
})
if (selectedFiles.value.length > 0) {
const payload = new FormData()
for (const file of selectedFiles.value) {
payload.append('files', file)
}
await api.post(`/api/student/orders/${createResponse.data.order_id}/attachments`, payload)
}
successMessage.value = '报修工单已提交,正在跳转到详情页。'
ElMessage.success('报修工单已提交')
setTimeout(() => {
router.push(`/student/orders/${createResponse.data.order_id}`)
}, 500)
} catch (err) {
errorMessage.value = getErrorMessage(err)
} finally {
submitting.value = false
}
}
onMounted(() => {
void loadAddresses()
})
</script>
<template>
<AppShell accent="student">
<h1 class="page-title">我要报修</h1>
<div class="repair-page">
<aside class="repair-side-card" :data-collapsed="sidebarCollapsed ? 'true' : undefined">
<button class="sidebar-toggle" type="button" @click="sidebarCollapsed = !sidebarCollapsed">
{{ sidebarCollapsed ? '展开' : '收起' }}
</button>
<div class="sidebar-content">
<h2>报修步骤</h2>
<ol>
<li v-for="item in completionItems" :key="item.label" :class="{ done: item.done }" @click="scrollToStep(completionItems.indexOf(item))">
{{ item.label }}
</li>
</ol>
<p v-if="!form.rawDescription.trim()">请先描述现场情况系统将自动整理故障信息</p>
<p v-else-if="!formReady">继续完成剩余步骤后即可提交</p>
<p v-else class="ready-hint">信息已齐全可以提交</p>
</div>
</aside>
<section class="repair-form-panel">
<div class="local-actions">
<button class="text-button" type="button" @click="router.push('/student/home')">返回首页</button>
<button class="text-button" type="button" @click="router.push('/student/orders')">我的工单</button>
</div>
<div v-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
<div v-if="successMessage" class="banner banner--success">{{ successMessage }}</div>
<section class="flat-section" data-step="step0">
<div class="flat-section__title">
<span>01</span>
<div>
<h2>故障描述</h2>
<p>请尽量详细地描述故障现象以便维修师傅携带工具可选使用AI辅助整理</p>
</div>
</div>
<label class="field">
<el-input
v-model="form.rawDescription"
type="textarea"
:rows="4"
maxlength="200"
show-word-limit
placeholder="例如:门口顶灯闪烁,有轻微焦味,影响整间宿舍照明"
/>
</label>
<div class="assistant-action-bar">
<el-button type="primary" :loading="diagnosing" @click="startDiagnosis" :disabled="!form.rawDescription.trim()">
{{ diagnosis?.draft ? '重新整理' : 'AI 整理描述' }}
</el-button>
</div>
<div v-if="flowStep === 'questions'" class="assistant-question-panel">
<div class="assistant-question-panel__title">
<strong>🔍 请补充以下信息</strong>
</div>
<label v-for="question in diagnosis?.questions ?? []" :key="question.id" class="field">
<span>{{ question.prompt }}</span>
<el-input v-model="questionAnswers[question.id]" />
</label>
<div class="button-row">
<el-button type="primary" :loading="diagnosing" :disabled="!canSubmitQuestions" @click="submitAnswers">生成草稿</el-button>
</div>
</div>
<div v-if="diagnosis?.draft" class="assistant-draft-panel">
<div v-for="item in assistantDraftItems" :key="item.label" class="draft-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="flat-section repair-block" data-step="step1" :data-done="sectionStatus.address || undefined">
<div class="flat-section__title">
<span>02</span>
<div>
<h2>故障地址</h2>
</div>
</div>
<div v-if="addressOptions.length" class="address-card-list">
<button
v-for="address in addressOptions"
:key="address.id"
class="address-select"
:data-active="selectedAddressId === address.id"
type="button"
@click="useAddress(address)"
>
<span class="address-select__dot" />
<div class="address-select__body">
<strong>{{ addressBadge(address) }}</strong>
<span>{{ address.campus }} / {{ address.building }} / {{ address.room }}</span>
</div>
<button
class="address-select__delete"
type="button"
title="删除此地址"
@click.stop="deleteAddress(address)"
>×</button>
</button>
</div>
<button v-if="!showAddressForm && addressOptions.length" class="address-add-button" type="button" @click="showAddressForm = true">+ 新增地址</button>
<div v-if="showAddressForm" class="inline-address-form">
<div class="inline-address-form__row">
<label class="field">
<span>校区</span>
<el-input v-model="addressDraft.campus" placeholder="西海岸校区" />
</label>
<label class="field">
<span>楼栋</span>
<el-input v-model="addressDraft.building" placeholder="如 听海苑5号" />
</label>
<label class="field">
<span>房间</span>
<el-input v-model="addressDraft.room" placeholder="如 718" />
</label>
</div>
<div class="inline-address-form__actions">
<button v-if="addressOptions.length" class="button button--ghost" type="button" @click="showAddressForm = false">取消</button>
<button class="button" type="button" @click="applyAddressDraft">使用这个地址</button>
</div>
</div>
</section>
<section class="flat-section repair-block" data-step="step2" :data-done="sectionStatus.category || undefined">
<div class="flat-section__title">
<span>03</span>
<div>
<h2>故障项目</h2>
</div>
</div>
<div class="repair-type-grid">
<label class="field">
<span>维修大类</span>
<el-select v-model="repairSelection.group" placeholder="请选择" @change="onRepairGroupChange">
<el-option v-for="group in repairGroups" :key="group.label" :label="group.label" :value="group.label" />
</el-select>
</label>
<label class="field">
<span>具体项目</span>
<el-select v-model="repairSelection.item" placeholder="请选择" @change="updateRepairMeta">
<el-option v-for="item in selectedRepairGroup.items" :key="item.label" :label="item.label" :value="item.label" />
</el-select>
</label>
<label class="field">
<span>紧急程度</span>
<el-select v-model="form.urgency" placeholder="请选择">
<el-option label="低" value="低" />
<el-option label="中" value="中" />
<el-option label="高" value="高" />
<el-option label="紧急" value="紧急" />
</el-select>
</label>
</div>
<div v-if="form.category" class="repair-derived">
<div><span>已选项目</span><strong>{{ selectedRepairLabel }}</strong></div>
<div><span>收费类型</span><strong>{{ chargeType }}</strong></div>
<div><span>处理优先级</span><strong>{{ form.urgency }}</strong></div>
</div>
</section>
<section class="flat-section repair-block">
<div class="flat-section__title">
<span>04</span>
<div>
<h2>现场材料</h2>
</div>
</div>
<div class="repair-inline-tools">
<div>
<span class="tool-label">故障数量</span>
<div class="quantity-control">
<button type="button" @click="changeFaultCount(-1)">-</button>
<span>{{ faultCount }}</span>
<button type="button" @click="changeFaultCount(1)">+</button>
</div>
</div>
<div>
<span class="tool-label">现场图片</span>
<el-upload
action="#"
accept="image/*"
:auto-upload="false"
:limit="3"
multiple
list-type="picture-card"
@change="onUploadChange"
@remove="onUploadChange"
>
<span class="service-upload__plus">+</span>
</el-upload>
<small class="upload-count"><span>{{ selectedFiles.length }}</span> / 3</small>
</div>
</div>
</section>
<section class="flat-section repair-block" data-step="step3" :data-done="sectionStatus.time || undefined">
<div class="flat-section__title">
<span>05</span>
<div>
<h2>期望维修时间</h2>
</div>
</div>
<div class="repair-type-grid">
<label class="field">
<span>选择日期</span>
<el-select v-model="form.expectedDate" placeholder="请选择">
<el-option label="不指定" value="" />
<el-option v-for="date in availableDates" :key="date" :label="date" :value="date" />
</el-select>
</label>
<label class="field">
<span>时间段</span>
<el-select v-model="form.expectedTimeSegment" placeholder="请选择">
<el-option v-for="segment in timeSegments" :key="segment" :label="segment" :value="segment === '不指定' ? '' : segment" />
</el-select>
</label>
</div>
<div class="room-entry-choice">
<span>是否允许无人时进入维修</span>
<label class="radio-row">
<input v-model="form.allowRoomEntry" type="radio" :value="true" />
<span>可以无需本人在场</span>
</label>
<label class="radio-row">
<input v-model="form.allowRoomEntry" type="radio" :value="false" />
<span>不可以需要本人在场</span>
</label>
</div>
</section>
<div v-if="diagnosis?.draft?.safety_risk" class="banner banner--warning">
识别到潜在安全风险请暂停使用相关设备
</div>
<label class="field repair-summary-field">
<span>工单摘要可修改</span>
<el-input v-model="form.structuredSummary" type="textarea" :rows="2" placeholder="系统将自动生成摘要,也可手动修改" />
</label>
<div class="submit-strip" :class="{ 'submit-strip--ready': formReady }" aria-label="提交操作栏">
<span class="submit-strip__hint">
<template v-if="!form.rawDescription.trim()">请先描述故障现象</template>
<template v-else-if="form.allowRoomEntry === null">请选择是否允许无人时进入维修</template>
<template v-else> 信息齐全可以提交</template>
</span>
<el-button type="primary" :disabled="!formReady" :loading="submitting" @click="submitOrder">
{{ submitting ? '提交中...' : '提交报修' }}
</el-button>
</div>
</section>
</div>
</AppShell>
</template>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"tsBuildInfoFile": "./.tsbuildinfo/app.tsbuildinfo",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./.tsbuildinfo/node.tsbuildinfo",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/element-plus') || id.includes('node_modules/@element-plus')) {
return 'element-plus'
}
if (
id.includes('node_modules/vue') ||
id.includes('node_modules/vue-router') ||
id.includes('node_modules/axios')
) {
return 'vendor'
}
return undefined
},
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
+30
View File
@@ -0,0 +1,30 @@
[project]
name = "dorm-repair-backend"
version = "0.1.0"
description = "交互式透明化宿舍报修系统 —— FastAPI 后端"
requires-python = ">=3.12"
dependencies = [
"bcrypt>=4,<5",
"fastapi>=0.115,<1",
"uvicorn[standard]>=0.30,<1",
"python-multipart>=0.0.9,<1",
"pydantic>=2.11,<3",
"httpx>=0.28,<1",
"python-dotenv>=1.0,<2",
]
[dependency-groups]
dev = [
"ruff>=0.15,<1",
]
[tool.uv]
package = false
[tool.ruff]
target-version = "py312"
line-length = 120
src = ["backend", "cli.py"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Demo data seed script — called by cli.py, not part of application code."""
from __future__ import annotations
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend"
sys.path.insert(0, str(BACKEND_DIR))
from app.core.security import hash_password # noqa: E402
from app.services.repository import create_event, init_db # noqa: E402
_now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def run(db_path: str, force: bool = False) -> None:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
init_db(conn)
if force:
conn.executescript("""
DELETE FROM feedback;
DELETE FROM order_events;
DELETE FROM attachments;
DELETE FROM saved_addresses;
DELETE FROM repair_orders;
DELETE FROM sessions;
DELETE FROM users;
""")
conn.commit()
existing = conn.execute("SELECT COUNT(*) AS count FROM repair_orders").fetchone()["count"]
if existing > 0:
print("Orders already exist, skipping. Use --force to overwrite.")
return
conn.executemany(
"INSERT OR IGNORE INTO users "
"(username, password_hash, role, display_name, created_at) "
"VALUES (?, ?, ?, ?, ?)",
[
("student01", hash_password("Student123"), "student", "张同学", _now),
("admin01", hash_password("Admin123"), "admin", "宿管老师", _now),
],
)
student = conn.execute("SELECT id FROM users WHERE username = 'student01'").fetchone()
if student is None:
conn.commit()
return
conn.execute(
"INSERT OR IGNORE INTO saved_addresses "
"(student_id, campus, building, room, last_used_at) "
"VALUES (?, ?, ?, ?, ?)",
(student["id"], "西海岸校区", "听海苑999号楼", "404", _now),
)
orders = [
{
"order_no": "DR20260601001",
"category": "给排水 / 水龙头类",
"raw_description": "卫生间水龙头关不严,一直滴水。",
"structured_summary": "给排水,建议管道处理。卫生间水龙头关不严持续滴水;洗手池位置;影响日常用水。",
"urgency": "",
"status": "已提交",
"allow_room_entry": 0,
"assignee_name": None,
"expected_date": None,
"expected_time_segment": None,
"expected_arrival_at": None,
"admin_note": None,
"rework_reason": None,
},
{
"order_no": "DR20260601002",
"category": "电路照明 / 照明故障",
"raw_description": "宿舍顶灯一直闪烁,还有轻微焦味。",
"structured_summary": "电路照明,建议电工处理。顶灯闪烁有焦味;门口位置;影响整间照明;有轻微焦糊味。",
"urgency": "紧急",
"status": "待处理",
"allow_room_entry": 0,
"assignee_name": "王师傅",
"expected_date": "2026-06-02",
"expected_time_segment": "09:00-11:00",
"expected_arrival_at": "明天上午",
"admin_note": "已派电工王师傅,请提前清理顶灯下方区域。",
"rework_reason": None,
},
{
"order_no": "DR20260601003",
"category": "空调设备 / 制冷制热异常",
"raw_description": "空调开到16度还是不制冷,出风口感觉是自然风。",
"structured_summary": (
"空调设备,建议空调维修处理。空调16度不制冷;出风口常温风;已持续两天影响休息。"
),
"urgency": "",
"status": "处理中",
"allow_room_entry": 1,
"assignee_name": "赵师傅",
"expected_date": "2026-06-02",
"expected_time_segment": "14:00-16:00",
"expected_arrival_at": "今天下午",
"admin_note": "已安排空调维修赵师傅上门检测,可能与制冷剂泄漏有关。",
"rework_reason": None,
},
{
"order_no": "DR20260601004",
"category": "门窗锁具 / 门锁钥匙",
"raw_description": "宿舍门锁很难拧开,偶尔会卡住。",
"structured_summary": (
"门窗锁具,建议门窗处理。门锁难拧开偶尔卡住;门口锁芯位置;影响进出;锁芯磨损严重。"
),
"urgency": "",
"status": "待上门",
"allow_room_entry": 0,
"assignee_name": "李师傅",
"expected_date": "2026-06-03",
"expected_time_segment": "14:00-16:00",
"expected_arrival_at": "6月3日下午",
"admin_note": "已安排门窗维修李师傅上门更换锁芯,请当天留人在宿舍。",
"rework_reason": None,
},
{
"order_no": "DR20260528001",
"category": "网络弱电 / 网口异常",
"raw_description": "宿舍网口插上网线没反应,灯不亮。",
"structured_summary": "网络弱电,建议弱电维修处理。网口插网线无反应灯不亮;书桌下方;影响上网学习。",
"urgency": "",
"status": "返工申请中",
"allow_room_entry": 1,
"assignee_name": "孙师傅",
"expected_date": None,
"expected_time_segment": None,
"expected_arrival_at": None,
"admin_note": "孙师傅已上门维修并更换网口面板,但学生反映问题仍存在,已进入返工流程。",
"rework_reason": "维修后网口仍无法使用,指示灯依然不亮,请重新排查线路问题。",
},
{
"order_no": "DR20260525003",
"category": "门窗锁具 / 窗户五金",
"raw_description": "宿舍窗户关不严,冬天漏风严重。",
"structured_summary": "门窗锁具,建议门窗处理。窗户关不严有缝隙;推拉窗轨道变形;冬天漏风影响室温。",
"urgency": "",
"status": "已完成",
"allow_room_entry": 1,
"assignee_name": "李师傅",
"expected_date": "2026-05-24",
"expected_time_segment": "09:00-11:00",
"expected_arrival_at": "5月24日上午",
"admin_note": "已更换窗户密封条并调整轨道,请学生确认。",
"rework_reason": None,
},
{
"order_no": "DR20260520001",
"category": "家具设施 / 桌椅维修",
"raw_description": "书桌抽屉轨道坏了,拉出来会掉。",
"structured_summary": "家具设施,建议木工维修处理。抽屉轨道损坏拉出脱落;右侧抽屉;影响日常存放。",
"urgency": "",
"status": "已确认",
"allow_room_entry": 0,
"assignee_name": "刘师傅",
"expected_date": "2026-05-20",
"expected_time_segment": "14:00-16:00",
"expected_arrival_at": "5月20日下午",
"admin_note": "已更换抽屉轨道,学生已确认维修结果。",
"rework_reason": None,
},
{
"order_no": "DR20260515001",
"category": "给排水 / 下水疏通类",
"raw_description": "洗手池下水很慢,水会积在池子里。",
"structured_summary": "给排水,建议管道疏通处理。洗手池下水缓慢积水;卫生间洗手池;影响日常洗漱。",
"urgency": "",
"status": "已取消",
"allow_room_entry": 0,
"assignee_name": None,
"expected_date": None,
"expected_time_segment": None,
"expected_arrival_at": None,
"admin_note": None,
"rework_reason": None,
},
]
for o in orders:
cur = conn.execute(
"INSERT INTO repair_orders ("
"order_no, student_id, campus, building, room, category, "
"raw_description, structured_summary, urgency, status, "
"allow_room_entry, assignee_name, expected_date, "
"expected_time_segment, expected_arrival_at, admin_note, "
"rework_reason, diagnosis_session_id, created_at, updated_at"
") VALUES ("
"?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
"?, ?, ?, ?, ?, ?, ?, ?, ?, ?"
")",
(
o["order_no"],
student["id"],
"西海岸校区",
"听海苑999号楼",
"404",
o["category"],
o["raw_description"],
o["structured_summary"],
o["urgency"],
o["status"],
o["allow_room_entry"],
o["assignee_name"],
o["expected_date"],
o["expected_time_segment"],
o["expected_arrival_at"],
o["admin_note"],
o["rework_reason"],
None,
_now,
_now,
),
)
oid = cur.lastrowid
create_event(
conn,
oid,
"student",
"张同学",
"created",
"学生提交报修",
o["raw_description"],
None,
"已提交",
)
if o["status"] != "已提交":
create_event(
conn,
oid,
"admin",
"宿管老师",
"status_updated",
"管理员更新工单状态",
o["admin_note"],
"已提交",
o["status"],
)
if o["status"] == "待上门":
expected_info = f"预计 {o['expected_date'] or ''} {o['expected_time_segment'] or ''}"
create_event(
conn,
oid,
"admin",
"宿管老师",
"scheduled",
"管理员安排上门时间",
expected_info,
o["status"],
o["status"],
)
if o["status"] == "返工申请中":
create_event(
conn,
oid,
"student",
"张同学",
"rework_requested",
"学生申请返工",
o["rework_reason"],
"已完成",
o["status"],
)
if o["status"] == "已确认":
create_event(
conn,
oid,
"student",
"张同学",
"confirmed",
"学生确认维修完成",
"维修结果满意,确认工单完成。",
"已完成",
o["status"],
)
if o["status"] == "已取消":
create_event(
conn,
oid,
"student",
"张同学",
"cancelled",
"学生取消工单",
"问题已自行解决,取消报修。",
o["status"],
o["status"],
)
if o["status"] in ("已完成", "已确认"):
conn.execute(
"INSERT INTO feedback (order_id, rating, comment, created_at) VALUES (?, ?, ?, ?)",
(oid, 5, "维修很快,问题已经解决。", _now),
)
conn.commit()
print("Seed complete.")
finally:
conn.close()
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--db", type=str, default=str(BACKEND_DIR / "data" / "dorm_repair.sqlite3"))
p.add_argument("--force", action="store_true")
args = p.parse_args()
run(args.db, args.force)
Generated
+638
View File
@@ -0,0 +1,638 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "bcrypt"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
{ url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
{ url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
{ url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
{ url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
{ url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
{ url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
{ url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
{ url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
{ url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
{ url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
{ url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
{ url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
{ url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
{ url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
{ url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
{ url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
{ url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
{ url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
{ url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
{ url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
{ url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
{ url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
{ url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dorm-repair-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = ">=4,<5" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pydantic", specifier = ">=2.11,<3" },
{ name = "python-dotenv", specifier = ">=1.0,<2" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15,<1" }]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
]
[[package]]
name = "starlette"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]