init
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
.runtime/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
backend/data/
|
||||
backend/uploads/
|
||||
*.sqlite3
|
||||
.env
|
||||
+42
@@ -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
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.analysis.extraPaths": ["backend"]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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 可选
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# 后端 API 地址
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@types/node@25.9.2'
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :data-status="status">{{ status }}</span>
|
||||
</template>
|
||||
@@ -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>
|
||||
Vendored
+8
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 '操作失败,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ACTIVE_STATUSES = new Set(['已提交', '待处理', '处理中', '待上门', '返工申请中'])
|
||||
export const CLOSED_STATUSES = new Set(['已完成', '已确认', '已取消'])
|
||||
@@ -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')
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user