Commit 21eca8c8 authored by 375242562@qq.com's avatar 375242562@qq.com

feat: 实现 RBAC 登录权限系统

后端:
- 新增用户、科室、角色、权限数据模型(SQLAlchemy)
- 实现 JWT 认证(PBKDF2-SHA256 密码加密)
- 新增 auth/users/roles/permissions/departments REST API
- 用户-角色、角色-权限通过关联表直接操作(避免异步懒加载问题)
- 新增 init_auth_data 初始化脚本(默认科室/角色/权限/管理员)

前端:
- 新增登录页(LoginPage)与 AuthContext 认证上下文
- 新增 PrivateRoute 路由守卫、PermissionButton/PermissionGuard 权限组件
- Sidebar 根据用户菜单权限动态过滤,TopBar 展示用户信息与退出登录
- 新增系统管理页(用户管理、角色管理含权限树、科室管理)
- 所有业务页面的新增/编辑/删除按钮接入权限控制,无权限时自动隐藏
Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 8897bbc8
# 权限系统使用指南
## 系统概述
智能患者招募系统现已集成完整的 RBAC(基于角色的访问控制)权限系统,支持:
- ✅ 用户登录认证(JWT Token)
- ✅ 科室管理
- ✅ 角色管理
- ✅ 权限管理(菜单权限 + 按钮权限)
- ✅ 用户-角色-权限三级关联
- ✅ 前端路由守卫和按钮权限控制
## 快速开始
### 1. 安装后端依赖
```bash
cd backend
uv pip install python-jose[cryptography] passlib[bcrypt] python-multipart
```
### 2. 初始化数据库和基础数据
```bash
# 运行初始化脚本创建默认数据
cd backend
python -m app.scripts.init_auth_data
```
这将创建:
- 4 个默认科室(肿瘤科、心血管科、内分泌科、神经内科)
- 默认菜单权限(患者管理、试验管理、诊断管理、匹配管理、医生工作站、系统管理)
- 默认按钮权限(添加、编辑、删除、查看等)
- 3 个默认角色(超级管理员、主任医师、住院医师)
- 1 个超级管理员账号
**默认超级管理员账号:**
- 用户名: `admin`
- 密码: `admin123`
- ⚠️ 生产环境请立即修改密码!
### 3. 启动后端服务
```bash
cd backend
uvicorn app.main:app --reload
```
后端 API 文档: http://localhost:8000/docs
### 4. 启动前端服务
```bash
cd frontend
npm run dev
```
前端地址: http://localhost:5173
## 系统功能
### 登录页面
- 地址: `/login`
- 输入用户名和密码登录
- 登录成功后自动跳转到首页
### 权限控制
#### 菜单权限
- 用户只能看到其角色拥有权限的菜单项
- 侧边栏会根据用户权限动态过滤
#### 按钮权限
使用 `PermissionButton` 组件:
```tsx
import { PermissionButton } from '@/components/auth/PermissionButton'
<PermissionButton
permissionCode="btn:patient:add"
variant="contained"
onClick={handleAdd}
>
添加患者
</PermissionButton>
```
#### 路由权限
`App.tsx` 中使用 `PrivateRoute` 组件:
```tsx
<Route
path="patients"
element={
<PrivateRoute requiredPermission="menu:patients">
<PatientsPage />
</PrivateRoute>
}
/>
```
#### 组件级权限控制
使用 `PermissionGuard` 组件:
```tsx
import { PermissionGuard } from '@/components/auth/PermissionGuard'
<PermissionGuard permissionCode="btn:patient:delete">
<Button color="error">删除</Button>
</PermissionGuard>
```
### 系统管理
超级管理员可以访问 `/system` 页面进行系统管理:
#### 用户管理
- 创建、编辑、删除用户
- 分配角色
- 重置密码
- 启用/禁用账号
#### 角色管理
- 创建、编辑、删除角色
- 为角色分配权限
- 启用/禁用角色
#### 科室管理
- 创建、编辑、删除科室
- 管理科室信息
## 默认角色权限说明
### 超级管理员 (SUPER_ADMIN)
- 拥有所有权限(代码层面无需分配具体权限)
- 可以访问系统管理页面
- 可以管理用户、角色、权限、科室
### 主任医师 (CHIEF_PHYSICIAN)
- 可访问:患者管理、试验管理、诊断管理、匹配管理、医生工作站
- 可操作:添加、编辑、删除患者/试验/诊断,执行匹配
### 住院医师 (RESIDENT_PHYSICIAN)
- 可访问:患者管理、试验管理、医生工作站
- 可操作:仅查看患者和试验信息(无编辑删除权限)
## 权限编码规范
### 菜单权限
格式: `menu:{模块名}`
示例:
- `menu:patients` - 患者管理菜单
- `menu:trials` - 试验管理菜单
- `menu:system` - 系统管理菜单
### 按钮权限
格式: `btn:{模块名}:{操作}`
示例:
- `btn:patient:add` - 添加患者
- `btn:patient:edit` - 编辑患者
- `btn:patient:delete` - 删除患者
- `btn:patient:view` - 查看患者详情
## API 接口
### 认证接口
#### 登录
```http
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
```
响应:
```json
{
"access_token": "eyJ...",
"token_type": "bearer",
"user": {
"id": "...",
"username": "admin",
"full_name": "系统管理员",
"permissions": [...],
"roles": [...]
}
}
```
#### 其他接口
所有其他接口需要在请求头中携带 token:
```http
Authorization: Bearer {access_token}
```
### 管理接口
- `GET /api/v1/users` - 获取用户列表
- `POST /api/v1/users` - 创建用户
- `PUT /api/v1/users/{id}` - 更新用户
- `DELETE /api/v1/users/{id}` - 删除用户
- `GET /api/v1/roles` - 获取角色列表
- `POST /api/v1/roles` - 创建角色
- `GET /api/v1/permissions` - 获取权限列表
- `GET /api/v1/departments` - 获取科室列表
## 前端核心文件
### 认证相关
- `src/contexts/AuthContext.tsx` - 认证上下文
- `src/services/authService.ts` - 认证服务
- `src/pages/LoginPage.tsx` - 登录页面
### 权限组件
- `src/components/auth/PrivateRoute.tsx` - 路由守卫
- `src/components/auth/PermissionButton.tsx` - 权限按钮
- `src/components/auth/PermissionGuard.tsx` - 权限守卫
### 系统管理
- `src/pages/SystemPage.tsx` - 系统管理主页面
- `src/components/system/UserManagement.tsx` - 用户管理
- `src/components/system/RoleManagement.tsx` - 角色管理
- `src/components/system/DepartmentManagement.tsx` - 科室管理
## 后端核心文件
### 数据模型
- `backend/app/models/user.py` - 用户模型
- `backend/app/models/department.py` - 科室模型
- `backend/app/models/role.py` - 角色模型
- `backend/app/models/permission.py` - 权限模型
- `backend/app/models/user_role.py` - 关联表
### 认证核心
- `backend/app/core/security.py` - JWT 和密码加密
- `backend/app/core/deps.py` - 认证依赖注入
### API 路由
- `backend/app/api/routes/auth.py` - 登录接口
- `backend/app/api/routes/users.py` - 用户管理
- `backend/app/api/routes/roles.py` - 角色管理
- `backend/app/api/routes/permissions.py` - 权限管理
- `backend/app/api/routes/departments.py` - 科室管理
## 数据库表结构
### users (用户表)
- id, username, password_hash, full_name
- email, phone, title, department_id
- is_active, is_superuser, last_login
### departments (科室表)
- id, name, code, description, is_active
### roles (角色表)
- id, name, code, description, is_active
### permissions (权限表)
- id, name, code, type (menu/button)
- path, icon, parent_id, menu_id
- sort_order, description, is_active
### user_roles (用户-角色关联表)
- user_id, role_id
### role_permissions (角色-权限关联表)
- role_id, permission_id
## 安全注意事项
1. **生产环境配置**
- 修改 `.env` 文件中的 `JWT_SECRET_KEY`
- 修改默认管理员密码
- 启用 HTTPS
2. **密码策略**
- 最小长度: 6 位(建议在生产环境提高到 8-12 位)
- 使用 bcrypt 加密存储
3. **Token 管理**
- Token 有效期: 24 小时(可在 `config.py` 中调整)
- Token 存储在 localStorage(前端)
- 401 错误自动跳转登录页
4. **权限验证**
- 前端验证(UX 优化)
- 后端验证(安全保障)
- 所有管理接口都需要超级管理员权限
## 常见问题
### Q: 如何添加新的权限?
A: 在系统管理 -> 权限管理中添加,或直接修改 `init_auth_data.py` 脚本重新初始化。
### Q: 如何为现有路由添加权限控制?
A: 使用 `PrivateRoute` 组件包裹路由,传入 `requiredPermission` 参数。
### Q: 忘记管理员密码怎么办?
A: 重新运行初始化脚本,或直接在数据库中更新密码哈希。
### Q: 如何实现更细粒度的权限控制?
A: 使用 `PermissionGuard` 组件包裹需要控制的 UI 元素,或在后端使用 `check_permission` 依赖。
## 下一步建议
1. ✅ 完善用户管理功能(批量导入、导出)
2. ✅ 添加操作日志记录
3. ✅ 实现权限缓存机制
4. ✅ 添加密码强度验证
5. ✅ 实现 2FA 双因素认证
6. ✅ 完善审计日志
---
**文档版本**: v1.0
**最后更新**: 2026-03-02
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from sqlalchemy.orm import selectinload
from datetime import datetime
from app.database import get_db
from app.core.security import verify_password, create_access_token
from app.models.user import User
from app.models.role import Role
from app.schemas.auth import LoginRequest, LoginResponse, UserInfo, RoleInfo, PermissionInfo, DepartmentInfo
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=LoginResponse, summary="用户登录")
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""
用户登录接口
- 验证用户名和密码
- 返回 JWT 令牌和用户信息(包含权限列表)
"""
# 查询用户并加载关联数据
result = await db.execute(
select(User)
.options(
selectinload(User.department),
selectinload(User.roles).selectinload(Role.permissions)
)
.where(User.username == login_data.username)
)
user = result.scalar_one_or_none()
# 验证用户存在性和密码
if not user or not verify_password(login_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# 检查用户是否被禁用
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用户账号已被禁用",
)
# 更新最后登录时间
await db.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.utcnow())
)
await db.commit()
# 创建访问令牌
access_token = create_access_token(data={"sub": user.id})
# 收集用户的所有权限
all_permissions = {}
roles_info = []
for role in user.roles:
if role.is_active:
roles_info.append(RoleInfo(
id=role.id,
name=role.name,
code=role.code,
description=role.description
))
for permission in role.permissions:
if permission.is_active:
all_permissions[permission.id] = PermissionInfo(
id=permission.id,
code=permission.code,
name=permission.name,
type=permission.type.value,
path=permission.path,
icon=permission.icon,
parent_id=permission.parent_id,
menu_id=permission.menu_id
)
# 构建用户信息
user_info = UserInfo(
id=user.id,
username=user.username,
full_name=user.full_name,
email=user.email,
phone=user.phone,
title=user.title,
is_active=user.is_active,
is_superuser=user.is_superuser,
department=DepartmentInfo(
id=user.department.id,
name=user.department.name,
code=user.department.code
) if user.department else None,
roles=roles_info,
permissions=list(all_permissions.values()),
last_login=user.last_login,
created_at=user.created_at
)
return LoginResponse(
access_token=access_token,
token_type="bearer",
user=user_info
)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
import uuid
from app.database import get_db
from app.core.deps import get_current_superuser
from app.models.department import Department
from app.models.user import User
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
router = APIRouter(prefix="/departments", tags=["科室管理"])
@router.get("", response_model=List[DepartmentResponse], summary="获取科室列表")
async def list_departments(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db)
):
"""获取所有科室列表(不需要认证,供登录页面使用)"""
result = await db.execute(
select(Department)
.where(Department.is_active == True)
.offset(skip)
.limit(limit)
)
departments = result.scalars().all()
return departments
@router.post("", response_model=DepartmentResponse, summary="创建科室")
async def create_department(
department_in: DepartmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""创建新科室(仅超级管理员)"""
# 检查名称是否已存在
result = await db.execute(
select(Department).where(Department.name == department_in.name)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="科室名称已存在")
# 检查编码是否已存在
if department_in.code:
result = await db.execute(
select(Department).where(Department.code == department_in.code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="科室编码已存在")
department = Department(
id=str(uuid.uuid4()),
**department_in.model_dump()
)
db.add(department)
await db.commit()
await db.refresh(department)
return department
@router.get("/{department_id}", response_model=DepartmentResponse, summary="获取科室详情")
async def get_department(
department_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取指定科室详情"""
result = await db.execute(
select(Department).where(Department.id == department_id)
)
department = result.scalar_one_or_none()
if not department:
raise HTTPException(status_code=404, detail="科室不存在")
return department
@router.put("/{department_id}", response_model=DepartmentResponse, summary="更新科室")
async def update_department(
department_id: str,
department_in: DepartmentUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""更新科室信息(仅超级管理员)"""
result = await db.execute(
select(Department).where(Department.id == department_id)
)
department = result.scalar_one_or_none()
if not department:
raise HTTPException(status_code=404, detail="科室不存在")
update_data = department_in.model_dump(exclude_unset=True)
# 检查名称重复
if "name" in update_data and update_data["name"] != department.name:
result = await db.execute(
select(Department).where(Department.name == update_data["name"])
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="科室名称已存在")
# 检查编码重复
if "code" in update_data and update_data["code"] != department.code:
result = await db.execute(
select(Department).where(Department.code == update_data["code"])
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="科室编码已存在")
for field, value in update_data.items():
setattr(department, field, value)
await db.commit()
await db.refresh(department)
return department
@router.delete("/{department_id}", summary="删除科室")
async def delete_department(
department_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""删除科室(仅超级管理员)"""
result = await db.execute(
select(Department).where(Department.id == department_id)
)
department = result.scalar_one_or_none()
if not department:
raise HTTPException(status_code=404, detail="科室不存在")
# 检查是否有用户关联
result = await db.execute(
select(User).where(User.department_id == department_id)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该科室下还有用户,无法删除")
await db.delete(department)
await db.commit()
return {"message": "科室删除成功"}
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
import uuid
from app.database import get_db
from app.core.deps import get_current_superuser
from app.models.permission import Permission
from app.models.user import User
from app.schemas.permission import PermissionCreate, PermissionUpdate, PermissionResponse
router = APIRouter(prefix="/permissions", tags=["权限管理"])
@router.get("", response_model=List[PermissionResponse], summary="获取权限列表")
async def list_permissions(
skip: int = 0,
limit: int = 500,
type: str = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取所有权限列表(仅超级管理员)"""
query = select(Permission)
if type:
query = query.where(Permission.type == type)
query = query.offset(skip).limit(limit).order_by(Permission.sort_order)
result = await db.execute(query)
permissions = result.scalars().all()
return permissions
@router.post("", response_model=PermissionResponse, summary="创建权限")
async def create_permission(
permission_in: PermissionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""创建新权限(仅超级管理员)"""
# 检查权限编码是否已存在
result = await db.execute(
select(Permission).where(Permission.code == permission_in.code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限编码已存在")
permission = Permission(
id=str(uuid.uuid4()),
**permission_in.model_dump()
)
db.add(permission)
await db.commit()
await db.refresh(permission)
return permission
@router.get("/{permission_id}", response_model=PermissionResponse, summary="获取权限详情")
async def get_permission(
permission_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取指定权限详情(仅超级管理员)"""
result = await db.execute(
select(Permission).where(Permission.id == permission_id)
)
permission = result.scalar_one_or_none()
if not permission:
raise HTTPException(status_code=404, detail="权限不存在")
return permission
@router.put("/{permission_id}", response_model=PermissionResponse, summary="更新权限")
async def update_permission(
permission_id: str,
permission_in: PermissionUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""更新权限信息(仅超级管理员)"""
result = await db.execute(
select(Permission).where(Permission.id == permission_id)
)
permission = result.scalar_one_or_none()
if not permission:
raise HTTPException(status_code=404, detail="权限不存在")
update_data = permission_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(permission, field, value)
await db.commit()
await db.refresh(permission)
return permission
@router.delete("/{permission_id}", summary="删除权限")
async def delete_permission(
permission_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""删除权限(仅超级管理员)"""
result = await db.execute(
select(Permission).where(Permission.id == permission_id)
)
permission = result.scalar_one_or_none()
if not permission:
raise HTTPException(status_code=404, detail="权限不存在")
await db.delete(permission)
await db.commit()
return {"message": "权限删除成功"}
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, delete as sql_delete
from sqlalchemy.orm import selectinload
from typing import List
import uuid
from app.database import get_db
from app.core.deps import get_current_superuser
from app.models.role import Role
from app.models.permission import Permission
from app.models.user import User
from app.models.user_role import role_permissions
from app.schemas.role import RoleCreate, RoleUpdate, RoleResponse, RoleWithPermissions
router = APIRouter(prefix="/roles", tags=["角色管理"])
@router.get("", response_model=List[RoleResponse], summary="获取角色列表")
async def list_roles(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取所有角色列表(仅超级管理员)"""
result = await db.execute(
select(Role)
.offset(skip)
.limit(limit)
)
roles = result.scalars().all()
return roles
@router.post("", response_model=RoleResponse, summary="创建角色")
async def create_role(
role_in: RoleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""创建新角色(仅超级管理员)"""
# 检查角色名称是否已存在
result = await db.execute(
select(Role).where(Role.name == role_in.name)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色名称已存在")
# 检查角色编码是否已存在
result = await db.execute(
select(Role).where(Role.code == role_in.code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色编码已存在")
# 创建角色
role_data = role_in.model_dump(exclude={"permission_ids"})
role = Role(
id=str(uuid.uuid4()),
**role_data
)
db.add(role)
await db.flush() # 确保 role.id 写入数据库
# 直接插入关联表,绕过 ORM 关系懒加载
if role_in.permission_ids:
await db.execute(
insert(role_permissions).values(
[{"role_id": role.id, "permission_id": pid} for pid in role_in.permission_ids]
)
)
await db.commit()
await db.refresh(role)
return role
@router.get("/{role_id}", response_model=RoleWithPermissions, summary="获取角色详情")
async def get_role(
role_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取指定角色详情(仅超级管理员)"""
result = await db.execute(
select(Role)
.options(selectinload(Role.permissions))
.where(Role.id == role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
return role
@router.put("/{role_id}", response_model=RoleResponse, summary="更新角色")
async def update_role(
role_id: str,
role_in: RoleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""更新角色信息(仅超级管理员)"""
# 预加载 permissions 关系
result = await db.execute(
select(Role)
.options(selectinload(Role.permissions))
.where(Role.id == role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
update_data = role_in.model_dump(exclude_unset=True, exclude={"permission_ids"})
# 检查名称重复
if "name" in update_data and update_data["name"] != role.name:
result = await db.execute(
select(Role).where(Role.name == update_data["name"])
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色名称已存在")
# 更新基本信息
for field, value in update_data.items():
setattr(role, field, value)
# 更新权限关联:先删除旧的,再插入新的
if role_in.permission_ids is not None:
await db.execute(
sql_delete(role_permissions).where(role_permissions.c.role_id == role_id)
)
if role_in.permission_ids:
await db.execute(
insert(role_permissions).values(
[{"role_id": role_id, "permission_id": pid} for pid in role_in.permission_ids]
)
)
await db.commit()
await db.refresh(role)
return role
@router.delete("/{role_id}", summary="删除角色")
async def delete_role(
role_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""删除角色(仅超级管理员)"""
result = await db.execute(
select(Role).where(Role.id == role_id)
)
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
await db.delete(role)
await db.commit()
return {"message": "角色删除成功"}
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, delete as sql_delete
from sqlalchemy.orm import selectinload
from typing import List
import uuid
from app.database import get_db
from app.core.deps import get_current_user, get_current_superuser
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.models.role import Role
from app.models.user_role import user_roles
from app.schemas.user import (
UserCreate,
UserUpdate,
UserResponse,
UserWithRoles,
UserChangePassword,
UserResetPassword
)
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("/me", response_model=UserResponse, summary="获取当前用户信息")
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""获取当前登录用户的信息"""
return current_user
@router.get("", response_model=List[UserResponse], summary="获取用户列表")
async def list_users(
skip: int = 0,
limit: int = 100,
department_id: str = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取所有用户列表(仅超级管理员)"""
query = select(User)
if department_id:
query = query.where(User.department_id == department_id)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
return users
@router.post("", response_model=UserResponse, summary="创建用户")
async def create_user(
user_in: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""创建新用户(仅超级管理员)"""
# 检查用户名是否已存在
result = await db.execute(
select(User).where(User.username == user_in.username)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
# 检查邮箱是否已存在
if user_in.email:
result = await db.execute(
select(User).where(User.email == user_in.email)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="邮箱已被使用")
# 创建用户
user_data = user_in.model_dump(exclude={"password", "role_ids"})
user = User(
id=str(uuid.uuid4()),
password_hash=get_password_hash(user_in.password),
**user_data
)
db.add(user)
await db.flush() # 确保 user.id 写入数据库
# 直接插入关联表,绕过 ORM 关系懒加载
if user_in.role_ids:
await db.execute(
insert(user_roles).values(
[{"user_id": user.id, "role_id": rid} for rid in user_in.role_ids]
)
)
await db.commit()
await db.refresh(user)
return user
@router.get("/{user_id}", response_model=UserWithRoles, summary="获取用户详情")
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""获取指定用户详情(仅超级管理员)"""
result = await db.execute(
select(User)
.options(selectinload(User.roles), selectinload(User.department))
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
@router.put("/{user_id}", response_model=UserResponse, summary="更新用户")
async def update_user(
user_id: str,
user_in: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""更新用户信息(仅超级管理员)"""
# 预加载 roles 关系
result = await db.execute(
select(User)
.options(selectinload(User.roles))
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
update_data = user_in.model_dump(exclude_unset=True, exclude={"role_ids"})
# 检查邮箱重复
if "email" in update_data and update_data["email"] != user.email:
result = await db.execute(
select(User).where(User.email == update_data["email"])
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="邮箱已被使用")
# 更新基本信息
for field, value in update_data.items():
setattr(user, field, value)
# 更新角色关联:先删除旧的,再插入新的
if user_in.role_ids is not None:
await db.execute(
sql_delete(user_roles).where(user_roles.c.user_id == user_id)
)
if user_in.role_ids:
await db.execute(
insert(user_roles).values(
[{"user_id": user_id, "role_id": rid} for rid in user_in.role_ids]
)
)
await db.commit()
await db.refresh(user)
return user
@router.post("/change-password", summary="修改密码")
async def change_password(
password_data: UserChangePassword,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""当前用户修改自己的密码"""
# 验证旧密码
if not verify_password(password_data.old_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="旧密码错误")
# 更新密码
current_user.password_hash = get_password_hash(password_data.new_password)
await db.commit()
return {"message": "密码修改成功"}
@router.post("/{user_id}/reset-password", summary="重置用户密码")
async def reset_password(
user_id: str,
password_data: UserResetPassword,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""管理员重置用户密码(仅超级管理员)"""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新密码
user.password_hash = get_password_hash(password_data.new_password)
await db.commit()
return {"message": "密码重置成功"}
@router.delete("/{user_id}", summary="删除用户")
async def delete_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_superuser)
):
"""删除用户(仅超级管理员)"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await db.delete(user)
await db.commit()
return {"message": "用户删除成功"}
...@@ -8,6 +8,11 @@ class Settings(BaseSettings): ...@@ -8,6 +8,11 @@ class Settings(BaseSettings):
secret_key: str = "dev-secret-key-change-in-production" secret_key: str = "dev-secret-key-change-in-production"
database_url: str = "sqlite+aiosqlite:///./smart_recruitment.db" database_url: str = "sqlite+aiosqlite:///./smart_recruitment.db"
# JWT 配置
jwt_secret_key: str = "jwt-secret-key-change-in-production"
jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 60 * 24 # 24 小时
openai_api_key: str = "" openai_api_key: str = ""
anthropic_api_key: str = "" anthropic_api_key: str = ""
llm_provider: str = "openai" llm_provider: str = "openai"
......
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
from app.models.role import Role
from app.models.permission import Permission
# HTTP Bearer 认证方案
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""获取当前登录用户"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证凭证",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
# 查询用户
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="用户账号已被禁用")
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""获取当前活跃用户"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="用户账号已被禁用")
return current_user
async def get_current_superuser(
current_user: User = Depends(get_current_user)
) -> User:
"""获取当前超级管理员用户"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="权限不足")
return current_user
async def check_permission(permission_code: str):
"""检查用户权限的依赖工厂"""
async def permission_checker(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> User:
# 超级管理员拥有所有权限
if current_user.is_superuser:
return current_user
# 查询用户的所有权限
from sqlalchemy.orm import selectinload
result = await db.execute(
select(User)
.options(selectinload(User.roles).selectinload(Role.permissions))
.where(User.id == current_user.id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=403, detail="用户不存在")
# 收集用户的所有权限编码
user_permissions = set()
for role in user.roles:
for permission in role.permissions:
if permission.is_active:
user_permissions.add(permission.code)
# 检查是否拥有所需权限
if permission_code not in user_permissions:
raise HTTPException(status_code=403, detail=f"缺少权限: {permission_code}")
return current_user
return permission_checker
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
import hashlib
import secrets
from app.core.config import settings
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
# 分离盐和哈希值
try:
salt, stored_hash = hashed_password.split('$')
# 使用相同的盐计算哈希
password_hash = hashlib.pbkdf2_hmac(
'sha256',
plain_password.encode('utf-8'),
salt.encode('utf-8'),
100000
)
return secrets.compare_digest(password_hash.hex(), stored_hash)
except Exception:
return False
def get_password_hash(password: str) -> str:
"""生成密码哈希"""
# 生成随机盐
salt = secrets.token_hex(16)
# 使用 PBKDF2 生成哈希
password_hash = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt.encode('utf-8'),
100000
)
# 返回格式: salt$hash
return f"{salt}${password_hash.hex()}"
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建 JWT 访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""解码 JWT 令牌"""
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
return payload
except JWTError:
return None
...@@ -16,6 +16,7 @@ async def get_db() -> AsyncSession: ...@@ -16,6 +16,7 @@ async def get_db() -> AsyncSession:
async def init_db(): async def init_db():
from app.models import patient, trial, matching, notification # noqa: F401 from app.models import patient, trial, matching, notification, diagnosis # noqa: F401
from app.models import user, department, role, permission, user_role # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
...@@ -3,6 +3,7 @@ from fastapi import FastAPI ...@@ -3,6 +3,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db from app.database import init_db
from app.api.routes import patients, trials, matching, notifications, diagnoses from app.api.routes import patients, trials, matching, notifications, diagnoses
from app.api.routes import auth, users, roles, permissions, departments
@asynccontextmanager @asynccontextmanager
...@@ -25,6 +26,14 @@ app.add_middleware( ...@@ -25,6 +26,14 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# 认证和权限相关路由
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(roles.router, prefix="/api/v1")
app.include_router(permissions.router, prefix="/api/v1")
app.include_router(departments.router, prefix="/api/v1")
# 业务功能路由
app.include_router(patients.router, prefix="/api/v1") app.include_router(patients.router, prefix="/api/v1")
app.include_router(trials.router, prefix="/api/v1") app.include_router(trials.router, prefix="/api/v1")
app.include_router(matching.router, prefix="/api/v1") app.include_router(matching.router, prefix="/api/v1")
......
...@@ -3,3 +3,10 @@ from app.models.trial import Trial, Criterion, TrialStatus, CriterionType # noq ...@@ -3,3 +3,10 @@ from app.models.trial import Trial, Criterion, TrialStatus, CriterionType # noq
from app.models.matching import MatchingResult, MatchStatus # noqa: F401 from app.models.matching import MatchingResult, MatchStatus # noqa: F401
from app.models.notification import Notification # noqa: F401 from app.models.notification import Notification # noqa: F401
from app.models.diagnosis import Diagnosis # noqa: F401 from app.models.diagnosis import Diagnosis # noqa: F401
# 权限系统模型
from app.models.user import User # noqa: F401
from app.models.department import Department # noqa: F401
from app.models.role import Role # noqa: F401
from app.models.permission import Permission, PermissionType # noqa: F401
from app.models.user_role import user_roles, role_permissions # noqa: F401
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Department(Base):
"""科室表"""
__tablename__ = "departments"
id = Column(String(36), primary_key=True)
name = Column(String(100), nullable=False, unique=True, comment="科室名称")
code = Column(String(50), unique=True, comment="科室编码")
description = Column(String(500), comment="科室描述")
is_active = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
users = relationship("User", back_populates="department")
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.database import Base
class PermissionType(str, enum.Enum):
"""权限类型"""
MENU = "menu" # 菜单权限
BUTTON = "button" # 按钮权限
class Permission(Base):
"""权限表(菜单和按钮权限)"""
__tablename__ = "permissions"
id = Column(String(36), primary_key=True)
name = Column(String(100), nullable=False, comment="权限名称")
code = Column(String(100), nullable=False, unique=True, comment="权限编码")
type = Column(SQLEnum(PermissionType), nullable=False, comment="权限类型")
# 菜单权限字段
parent_id = Column(String(36), comment="父级菜单ID(用于树形结构)")
path = Column(String(200), comment="路由路径")
icon = Column(String(50), comment="菜单图标")
sort_order = Column(String(10), default="0", comment="排序")
# 按钮权限字段
menu_id = Column(String(36), comment="所属菜单ID(按钮权限用)")
description = Column(String(500), comment="权限描述")
is_active = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
roles = relationship("Role", secondary="role_permissions", back_populates="permissions")
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Role(Base):
"""角色表"""
__tablename__ = "roles"
id = Column(String(36), primary_key=True)
name = Column(String(50), nullable=False, unique=True, comment="角色名称")
code = Column(String(50), unique=True, comment="角色编码")
description = Column(String(500), comment="角色描述")
is_active = Column(Boolean, default=True, comment="是否启用")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
users = relationship("User", secondary="user_roles", back_populates="roles")
permissions = relationship("Permission", secondary="role_permissions", back_populates="roles")
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class User(Base):
"""用户表(医生账号)"""
__tablename__ = "users"
id = Column(String(36), primary_key=True)
username = Column(String(50), nullable=False, unique=True, comment="用户名")
password_hash = Column(String(255), nullable=False, comment="密码哈希")
full_name = Column(String(100), nullable=False, comment="姓名")
email = Column(String(100), unique=True, comment="邮箱")
phone = Column(String(20), comment="电话")
title = Column(String(50), comment="职称(主任医师、副主任医师等)")
department_id = Column(String(36), ForeignKey("departments.id"), comment="所属科室")
is_active = Column(Boolean, default=True, comment="账号是否启用")
is_superuser = Column(Boolean, default=False, comment="是否超级管理员")
last_login = Column(DateTime, comment="最后登录时间")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
department = relationship("Department", back_populates="users")
roles = relationship("Role", secondary="user_roles", back_populates="users")
from sqlalchemy import Column, String, ForeignKey, DateTime, Table
from datetime import datetime
from app.database import Base
# 用户-角色关联表
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", String(36), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime, default=datetime.utcnow)
)
# 角色-权限关联表
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", String(36), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", String(36), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime, default=datetime.utcnow)
)
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class LoginRequest(BaseModel):
"""登录请求"""
username: str = Field(..., description="用户名")
password: str = Field(..., description="密码")
class TokenResponse(BaseModel):
"""Token 响应"""
access_token: str = Field(..., description="访问令牌")
token_type: str = Field(default="bearer", description="令牌类型")
class PermissionInfo(BaseModel):
"""权限信息"""
id: str
code: str
name: str
type: str
path: Optional[str] = None
icon: Optional[str] = None
parent_id: Optional[str] = None
menu_id: Optional[str] = None
class RoleInfo(BaseModel):
"""角色信息"""
id: str
name: str
code: str
description: Optional[str] = None
class DepartmentInfo(BaseModel):
"""科室信息"""
id: str
name: str
code: Optional[str] = None
class UserInfo(BaseModel):
"""用户信息"""
id: str
username: str
full_name: str
email: Optional[str] = None
phone: Optional[str] = None
title: Optional[str] = None
is_active: bool
is_superuser: bool
department: Optional[DepartmentInfo] = None
roles: List[RoleInfo] = []
permissions: List[PermissionInfo] = []
last_login: Optional[datetime] = None
created_at: datetime
class LoginResponse(BaseModel):
"""登录响应"""
access_token: str = Field(..., description="访问令牌")
token_type: str = Field(default="bearer", description="令牌类型")
user: UserInfo = Field(..., description="用户信息")
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class DepartmentBase(BaseModel):
"""科室基础模型"""
name: str = Field(..., min_length=2, max_length=100, description="科室名称")
code: Optional[str] = Field(None, max_length=50, description="科室编码")
description: Optional[str] = Field(None, max_length=500, description="科室描述")
is_active: bool = Field(True, description="是否启用")
class DepartmentCreate(DepartmentBase):
"""创建科室"""
pass
class DepartmentUpdate(BaseModel):
"""更新科室"""
name: Optional[str] = Field(None, min_length=2, max_length=100)
code: Optional[str] = Field(None, max_length=50)
description: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = None
class DepartmentResponse(DepartmentBase):
"""科室响应"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from app.models.permission import PermissionType
class PermissionBase(BaseModel):
"""权限基础模型"""
name: str = Field(..., min_length=2, max_length=100, description="权限名称")
code: str = Field(..., min_length=2, max_length=100, description="权限编码")
type: PermissionType = Field(..., description="权限类型")
parent_id: Optional[str] = Field(None, description="父级菜单ID")
path: Optional[str] = Field(None, max_length=200, description="路由路径")
icon: Optional[str] = Field(None, max_length=50, description="菜单图标")
sort_order: str = Field("0", description="排序")
menu_id: Optional[str] = Field(None, description="所属菜单ID(按钮权限)")
description: Optional[str] = Field(None, max_length=500, description="权限描述")
is_active: bool = Field(True, description="是否启用")
class PermissionCreate(PermissionBase):
"""创建权限"""
pass
class PermissionUpdate(BaseModel):
"""更新权限"""
name: Optional[str] = Field(None, min_length=2, max_length=100)
path: Optional[str] = Field(None, max_length=200)
icon: Optional[str] = Field(None, max_length=50)
sort_order: Optional[str] = None
description: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = None
class PermissionResponse(PermissionBase):
"""权限响应"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class RoleBase(BaseModel):
"""角色基础模型"""
name: str = Field(..., min_length=2, max_length=50, description="角色名称")
code: str = Field(..., min_length=2, max_length=50, description="角色编码")
description: Optional[str] = Field(None, max_length=500, description="角色描述")
is_active: bool = Field(True, description="是否启用")
class RoleCreate(RoleBase):
"""创建角色"""
permission_ids: List[str] = Field(default_factory=list, description="权限ID列表")
class RoleUpdate(BaseModel):
"""更新角色"""
name: Optional[str] = Field(None, min_length=2, max_length=50)
description: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = None
permission_ids: Optional[List[str]] = None
class RoleResponse(RoleBase):
"""角色响应"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class RoleWithPermissions(RoleResponse):
"""带权限的角色响应"""
permissions: List["PermissionResponse"] = []
# 防止循环导入
from app.schemas.permission import PermissionResponse # noqa: E402
RoleWithPermissions.model_rebuild()
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
from datetime import datetime
class UserBase(BaseModel):
"""用户基础模型"""
username: str = Field(..., min_length=3, max_length=50, description="用户名")
full_name: str = Field(..., min_length=2, max_length=100, description="姓名")
email: Optional[EmailStr] = Field(None, description="邮箱")
phone: Optional[str] = Field(None, max_length=20, description="电话")
title: Optional[str] = Field(None, max_length=50, description="职称")
department_id: Optional[str] = Field(None, description="科室ID")
is_active: bool = Field(True, description="是否启用")
class UserCreate(UserBase):
"""创建用户"""
password: str = Field(..., min_length=6, description="密码")
role_ids: List[str] = Field(default_factory=list, description="角色ID列表")
class UserUpdate(BaseModel):
"""更新用户"""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
email: Optional[EmailStr] = None
phone: Optional[str] = Field(None, max_length=20)
title: Optional[str] = Field(None, max_length=50)
department_id: Optional[str] = None
is_active: Optional[bool] = None
role_ids: Optional[List[str]] = None
class UserChangePassword(BaseModel):
"""修改密码"""
old_password: str = Field(..., description="旧密码")
new_password: str = Field(..., min_length=6, description="新密码")
class UserResetPassword(BaseModel):
"""重置密码(管理员)"""
new_password: str = Field(..., min_length=6, description="新密码")
class UserResponse(UserBase):
"""用户响应"""
id: str
is_superuser: bool
last_login: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class RoleSimple(BaseModel):
"""简化的角色信息"""
id: str
name: str
code: str
class Config:
from_attributes = True
class UserWithRoles(UserResponse):
"""包含角色的用户响应"""
roles: List[RoleSimple] = []
"""
初始化权限系统的基础数据
包括:默认科室、默认角色、默认权限、超级管理员账号
"""
import asyncio
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.user import User
from app.models.department import Department
from app.models.role import Role
from app.models.permission import Permission, PermissionType
from app.core.security import get_password_hash
# 默认科室数据
DEFAULT_DEPARTMENTS = [
{"name": "肿瘤科", "code": "ONCOLOGY", "description": "肿瘤诊疗科室"},
{"name": "心血管科", "code": "CARDIOLOGY", "description": "心血管疾病诊疗科室"},
{"name": "内分泌科", "code": "ENDOCRINOLOGY", "description": "内分泌疾病诊疗科室"},
{"name": "神经内科", "code": "NEUROLOGY", "description": "神经系统疾病诊疗科室"},
]
# 默认菜单权限
DEFAULT_MENU_PERMISSIONS = [
{
"name": "患者管理",
"code": "menu:patients",
"type": PermissionType.MENU,
"path": "/patients",
"icon": "PersonIcon",
"sort_order": "1",
},
{
"name": "试验管理",
"code": "menu:trials",
"type": PermissionType.MENU,
"path": "/trials",
"icon": "ScienceIcon",
"sort_order": "2",
},
{
"name": "诊断管理",
"code": "menu:diagnoses",
"type": PermissionType.MENU,
"path": "/diagnoses",
"icon": "MedicalServicesIcon",
"sort_order": "3",
},
{
"name": "匹配管理",
"code": "menu:matching",
"type": PermissionType.MENU,
"path": "/matching",
"icon": "MatchIcon",
"sort_order": "4",
},
{
"name": "医生工作站",
"code": "menu:workstation",
"type": PermissionType.MENU,
"path": "/workstation",
"icon": "WorkIcon",
"sort_order": "5",
},
{
"name": "系统管理",
"code": "menu:system",
"type": PermissionType.MENU,
"path": "/system",
"icon": "SettingsIcon",
"sort_order": "6",
},
]
# 默认按钮权限
DEFAULT_BUTTON_PERMISSIONS = [
# 患者管理按钮
{"name": "添加患者", "code": "btn:patient:add", "type": PermissionType.BUTTON, "menu_id": "menu:patients"},
{"name": "编辑患者", "code": "btn:patient:edit", "type": PermissionType.BUTTON, "menu_id": "menu:patients"},
{"name": "删除患者", "code": "btn:patient:delete", "type": PermissionType.BUTTON, "menu_id": "menu:patients"},
{"name": "查看患者详情", "code": "btn:patient:view", "type": PermissionType.BUTTON, "menu_id": "menu:patients"},
# 试验管理按钮
{"name": "添加试验", "code": "btn:trial:add", "type": PermissionType.BUTTON, "menu_id": "menu:trials"},
{"name": "编辑试验", "code": "btn:trial:edit", "type": PermissionType.BUTTON, "menu_id": "menu:trials"},
{"name": "删除试验", "code": "btn:trial:delete", "type": PermissionType.BUTTON, "menu_id": "menu:trials"},
{"name": "查看试验详情", "code": "btn:trial:view", "type": PermissionType.BUTTON, "menu_id": "menu:trials"},
# 诊断管理按钮
{"name": "添加诊断", "code": "btn:diagnosis:add", "type": PermissionType.BUTTON, "menu_id": "menu:diagnoses"},
{"name": "编辑诊断", "code": "btn:diagnosis:edit", "type": PermissionType.BUTTON, "menu_id": "menu:diagnoses"},
{"name": "删除诊断", "code": "btn:diagnosis:delete", "type": PermissionType.BUTTON, "menu_id": "menu:diagnoses"},
# 匹配管理按钮
{"name": "执行匹配", "code": "btn:matching:execute", "type": PermissionType.BUTTON, "menu_id": "menu:matching"},
{"name": "查看匹配详情", "code": "btn:matching:view", "type": PermissionType.BUTTON, "menu_id": "menu:matching"},
# 系统管理按钮
{"name": "添加用户", "code": "btn:user:add", "type": PermissionType.BUTTON, "menu_id": "menu:system"},
{"name": "编辑用户", "code": "btn:user:edit", "type": PermissionType.BUTTON, "menu_id": "menu:system"},
{"name": "删除用户", "code": "btn:user:delete", "type": PermissionType.BUTTON, "menu_id": "menu:system"},
{"name": "重置密码", "code": "btn:user:reset", "type": PermissionType.BUTTON, "menu_id": "menu:system"},
]
# 默认角色
DEFAULT_ROLES = [
{
"name": "超级管理员",
"code": "SUPER_ADMIN",
"description": "系统超级管理员,拥有所有权限",
"permission_codes": [], # 超级管理员不需要分配权限
},
{
"name": "主任医师",
"code": "CHIEF_PHYSICIAN",
"description": "主任医师,拥有所有临床功能权限",
"permission_codes": [
"menu:patients", "menu:trials", "menu:diagnoses", "menu:matching", "menu:workstation",
"btn:patient:add", "btn:patient:edit", "btn:patient:delete", "btn:patient:view",
"btn:trial:add", "btn:trial:edit", "btn:trial:delete", "btn:trial:view",
"btn:diagnosis:add", "btn:diagnosis:edit", "btn:diagnosis:delete",
"btn:matching:execute", "btn:matching:view",
],
},
{
"name": "住院医师",
"code": "RESIDENT_PHYSICIAN",
"description": "住院医师,拥有基本查看和操作权限",
"permission_codes": [
"menu:patients", "menu:trials", "menu:workstation",
"btn:patient:view", "btn:trial:view",
],
},
]
async def init_departments(db: AsyncSession):
"""初始化科室数据"""
print("正在初始化科室数据...")
for dept_data in DEFAULT_DEPARTMENTS:
result = await db.execute(
select(Department).where(Department.code == dept_data["code"])
)
existing = result.scalar_one_or_none()
if not existing:
dept = Department(
id=str(uuid.uuid4()),
**dept_data
)
db.add(dept)
print(f" + 创建科室: {dept_data['name']}")
else:
print(f" - 科室已存在: {dept_data['name']}")
await db.commit()
async def init_permissions(db: AsyncSession):
"""初始化权限数据"""
print("\n正在初始化权限数据...")
all_permissions = DEFAULT_MENU_PERMISSIONS + DEFAULT_BUTTON_PERMISSIONS
for perm_data in all_permissions:
result = await db.execute(
select(Permission).where(Permission.code == perm_data["code"])
)
existing = result.scalar_one_or_none()
if not existing:
perm = Permission(
id=str(uuid.uuid4()),
**perm_data
)
db.add(perm)
print(f" + 创建权限: {perm_data['name']} ({perm_data['code']})")
else:
print(f" - 权限已存在: {perm_data['name']}")
await db.commit()
async def init_roles(db: AsyncSession):
"""初始化角色数据"""
print("\n正在初始化角色数据...")
for role_data in DEFAULT_ROLES:
result = await db.execute(
select(Role).where(Role.code == role_data["code"])
)
existing = result.scalar_one_or_none()
if not existing:
# 查找权限
permissions = []
if role_data["permission_codes"]:
result = await db.execute(
select(Permission).where(Permission.code.in_(role_data["permission_codes"]))
)
permissions = result.scalars().all()
role = Role(
id=str(uuid.uuid4()),
name=role_data["name"],
code=role_data["code"],
description=role_data["description"],
)
role.permissions = permissions
db.add(role)
print(f" + 创建角色: {role_data['name']} (关联 {len(permissions)} 个权限)")
else:
print(f" - 角色已存在: {role_data['name']}")
await db.commit()
async def init_superuser(db: AsyncSession):
"""初始化超级管理员账号"""
print("\n正在初始化超级管理员账号...")
# 检查是否已存在
result = await db.execute(
select(User).where(User.username == "admin")
)
existing = result.scalar_one_or_none()
if existing:
print(" - 超级管理员账号已存在")
return
# 获取默认科室
result = await db.execute(
select(Department).where(Department.code == "ONCOLOGY")
)
dept = result.scalar_one_or_none()
# 创建超级管理员
admin = User(
id=str(uuid.uuid4()),
username="admin",
password_hash=get_password_hash("admin123"), # 默认密码
full_name="系统管理员",
email="admin@hospital.com",
title="管理员",
department_id=dept.id if dept else None,
is_active=True,
is_superuser=True,
)
db.add(admin)
await db.commit()
print(" + 创建超级管理员账号")
print(" 用户名: admin")
print(" 密码: admin123")
print(" ! 请在生产环境中立即修改默认密码!")
async def main():
"""主函数"""
print("=" * 60)
print("开始初始化权限系统基础数据")
print("=" * 60)
async with AsyncSessionLocal() as db:
await init_departments(db)
await init_permissions(db)
await init_roles(db)
await init_superuser(db)
print("\n" + "=" * 60)
print("权限系统基础数据初始化完成!")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
...@@ -9,11 +9,16 @@ dependencies = [ ...@@ -9,11 +9,16 @@ dependencies = [
"aiosqlite>=0.20.0", "aiosqlite>=0.20.0",
"pydantic>=2.7.0", "pydantic>=2.7.0",
"pydantic-settings>=2.2.0", "pydantic-settings>=2.2.0",
"pydantic[email]>=2.7.0",
"langchain>=0.2.0", "langchain>=0.2.0",
"langchain-openai>=0.1.0", "langchain-openai>=0.1.0",
"langchain-anthropic>=0.1.0", "langchain-anthropic>=0.1.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"python-jose[cryptography]>=3.3.0",
"passlib>=1.7.4",
"bcrypt==4.1.3",
"python-multipart>=0.0.9",
] ]
[tool.uv] [tool.uv]
......
...@@ -71,6 +71,38 @@ wheels = [ ...@@ -71,6 +71,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
] ]
[[package]]
name = "bcrypt"
version = "4.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/e9/0b36987abbcd8c9210c7b86673d88ff0a481b4610630710fb80ba5661356/bcrypt-4.1.3.tar.gz", hash = "sha256:2ee15dd749f5952fe3f0430d0ff6b74082e159c50332a1413d51b5689cf06623", size = 26456, upload-time = "2024-05-04T04:12:51.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4e/e424a74f0749998d8465c162c5cb9d9f210a5b60444f4120eff0af3fa800/bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:48429c83292b57bf4af6ab75809f8f4daf52aa5d480632e53707805cc1ce9b74", size = 506501, upload-time = "2024-05-04T04:12:07.711Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8d/ad2efe0ec57ed3c25e588c4543d946a1c72f8ee357a121c0e382d8aaa93f/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a8bea4c152b91fd8319fef4c6a790da5c07840421c2b785084989bf8bbb7455", size = 284345, upload-time = "2024-05-04T04:12:09.243Z" },
{ url = "https://files.pythonhosted.org/packages/2f/f6/9c0a6de7ef78d573e10d0b7de3ef82454e2e6eb6fada453ea6c2b8fb3f0a/bcrypt-4.1.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d3b317050a9a711a5c7214bf04e28333cf528e0ed0ec9a4e55ba628d0f07c1a", size = 283395, upload-time = "2024-05-04T04:12:11.116Z" },
{ url = "https://files.pythonhosted.org/packages/63/56/45312e49c195cd30e1bf4b7f0e039e8b3c46802cd55485947ddcb96caa27/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:094fd31e08c2b102a14880ee5b3d09913ecf334cd604af27e1013c76831f7b05", size = 284794, upload-time = "2024-05-04T04:12:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/4c/6a/ce950d4350c734bc5d9b7196a58fedbdc94f564c00b495a1222984431e03/bcrypt-4.1.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4fb253d65da30d9269e0a6f4b0de32bd657a0208a6f4e43d3e645774fb5457f3", size = 283689, upload-time = "2024-05-04T04:12:14.289Z" },
{ url = "https://files.pythonhosted.org/packages/af/a1/36aa84027ef45558b30a485bc5b0606d5e7357b27b10cc49dce3944f4d1d/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:193bb49eeeb9c1e2db9ba65d09dc6384edd5608d9d672b4125e9320af9153a15", size = 318065, upload-time = "2024-05-04T04:12:15.641Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e8/183ead5dd8124e463d0946dfaf86c658225adde036aede8384d21d1794d0/bcrypt-4.1.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8cbb119267068c2581ae38790e0d1fbae65d0725247a930fc9900c285d95725d", size = 315556, upload-time = "2024-05-04T04:12:17.921Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5e/edcb4ec57b056ca9d5f9fde31fcda10cc635def48867edff5cc09a348a4f/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6cac78a8d42f9d120b3987f82252bdbeb7e6e900a5e1ba37f6be6fe4e3848286", size = 324438, upload-time = "2024-05-04T04:12:19.823Z" },
{ url = "https://files.pythonhosted.org/packages/3b/5d/121130cc85009070fe4e4f5937b213a00db143147bc6c8677b3fd03deec8/bcrypt-4.1.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01746eb2c4299dd0ae1670234bf77704f581dd72cc180f444bfe74eb80495b64", size = 335368, upload-time = "2024-05-04T04:12:21.055Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ac/bcb7d3ac8a1107b103f4a95c5be088b984d8045d4150294459a657870bd9/bcrypt-4.1.3-cp37-abi3-win32.whl", hash = "sha256:037c5bf7c196a63dcce75545c8874610c600809d5d82c305dd327cd4969995bf", size = 167120, upload-time = "2024-05-04T04:12:23.021Z" },
{ url = "https://files.pythonhosted.org/packages/69/57/3856b1728018f5ce85bb678a76e939cb154a2e1f9c5aa69b83ec5652b111/bcrypt-4.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:8a893d192dfb7c8e883c4576813bf18bb9d59e2cfd88b68b725990f033f1b978", size = 158059, upload-time = "2024-05-04T04:12:25.256Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/fbea8d2b370a4cc7f5f0aff9f492177a5813e130edeab9dd388ddd3ef1dc/bcrypt-4.1.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d4cf6ef1525f79255ef048b3489602868c47aea61f375377f0d00514fe4a78c", size = 506522, upload-time = "2024-05-04T04:12:27.206Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9a/4aa31d1de9369737cfa734a60c3d125ecbd1b3ae2c6499986d0ac160ea8b/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5698ce5292a4e4b9e5861f7e53b1d89242ad39d54c3da451a93cac17b61921a", size = 284401, upload-time = "2024-05-04T04:12:29.098Z" },
{ url = "https://files.pythonhosted.org/packages/12/d4/13b86b1bb2969a804c2347d0ad72fc3d3d9f5cf0d876c84451c6480e19bc/bcrypt-4.1.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec3c2e1ca3e5c4b9edb94290b356d082b721f3f50758bce7cce11d8a7c89ce84", size = 283414, upload-time = "2024-05-04T04:12:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/29/3c/6e478265f68eff764571676c0773086d15378fdf5347ddf53e5025c8b956/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3a5be252fef513363fe281bafc596c31b552cf81d04c5085bc5dac29670faa08", size = 284951, upload-time = "2024-05-04T04:12:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/97/00/21e34b365b895e6faf9cc5d4e7b97dd419e08f8a7df119792ec206b4a3fa/bcrypt-4.1.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5f7cd3399fbc4ec290378b541b0cf3d4398e4737a65d0f938c7c0f9d5e686611", size = 283703, upload-time = "2024-05-04T04:12:34.794Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c9/069b0c3683ce969b328b7b3e3218f9d5981d0629f6091b3b1dfa72928f75/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:c4c8d9b3e97209dd7111bf726e79f638ad9224b4691d1c7cfefa571a09b1b2d6", size = 317876, upload-time = "2024-05-04T04:12:36.108Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fd/0d2d7cc6fc816010f6c6273b778e2f147e2eca1144975b6b71e344b26ca0/bcrypt-4.1.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:31adb9cbb8737a581a843e13df22ffb7c84638342de3708a98d5c986770f2834", size = 315555, upload-time = "2024-05-04T04:12:37.536Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/283450ee672719e216a5e1b0e80cb0c8f225bc0814cbb893155ee4fdbb9e/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:551b320396e1d05e49cc18dd77d970accd52b322441628aca04801bbd1d52a73", size = 324408, upload-time = "2024-05-04T04:12:38.882Z" },
{ url = "https://files.pythonhosted.org/packages/9c/64/a016d23b6f513282d8b7f9dd91342929a2e970b2e2c2576d9b76f8f2ee5a/bcrypt-4.1.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6717543d2c110a155e6821ce5670c1f512f602eabb77dba95717ca76af79867d", size = 335334, upload-time = "2024-05-04T04:12:40.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/35/036d69e46c60394f2ffb474c9a4b3783e84395bf4ad55176771f603069ca/bcrypt-4.1.3-cp39-abi3-win32.whl", hash = "sha256:6004f5229b50f8493c49232b8e75726b568535fd300e5039e255d919fc3a07f2", size = 167071, upload-time = "2024-05-04T04:12:42.256Z" },
{ url = "https://files.pythonhosted.org/packages/b1/46/fada28872f3f3e121868f4cd2d61dcdc7085a07821debf1320cafeabc0db/bcrypt-4.1.3-cp39-abi3-win_amd64.whl", hash = "sha256:2505b54afb074627111b5a8dc9b6ae69d0f01fea65c2fcaea403448c503d3991", size = 158124, upload-time = "2024-05-04T04:12:44.085Z" },
{ url = "https://files.pythonhosted.org/packages/20/02/c23ca3cf171798af16361576ef43bfc3192e3c3a679bc557e046b02c7188/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:cb9c707c10bddaf9e5ba7cdb769f3e889e60b7d4fea22834b261f51ca2b89fed", size = 282631, upload-time = "2024-05-04T04:12:45.231Z" },
{ url = "https://files.pythonhosted.org/packages/f6/79/5dd98e9f67701be98d52f7ee181aa3b078d45eab62ed5f9aa987676d049c/bcrypt-4.1.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9f8ea645eb94fb6e7bea0cf4ba121c07a3a182ac52876493870033141aa687bc", size = 281342, upload-time = "2024-05-04T04:12:47.174Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
...@@ -80,6 +112,88 @@ wheels = [ ...@@ -80,6 +112,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
] ]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.4" version = "3.4.4"
...@@ -190,6 +304,66 @@ wheels = [ ...@@ -190,6 +304,66 @@ 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" }, { 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 = "cryptography"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
...@@ -199,6 +373,15 @@ wheels = [ ...@@ -199,6 +373,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
] ]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]] [[package]]
name = "docstring-parser" name = "docstring-parser"
version = "0.17.0" version = "0.17.0"
...@@ -208,6 +391,31 @@ wheels = [ ...@@ -208,6 +391,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
] ]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.3.1" version = "1.3.1"
...@@ -808,6 +1016,15 @@ wheels = [ ...@@ -808,6 +1016,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
...@@ -817,6 +1034,24 @@ wheels = [ ...@@ -817,6 +1034,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
...@@ -832,6 +1067,11 @@ wheels = [ ...@@ -832,6 +1067,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
] ]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.41.5" version = "2.41.5"
...@@ -1014,6 +1254,34 @@ wheels = [ ...@@ -1014,6 +1254,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
...@@ -1226,20 +1494,45 @@ wheels = [ ...@@ -1226,20 +1494,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "smart-recruitment-backend" name = "smart-recruitment-backend"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-anthropic" }, { name = "langchain-anthropic" },
{ name = "langchain-openai" }, { name = "langchain-openai" },
{ name = "pydantic" }, { name = "passlib" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
...@@ -1253,14 +1546,19 @@ dev = [ ...@@ -1253,14 +1546,19 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" }, { name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "bcrypt", specifier = "==4.1.3" },
{ name = "fastapi", specifier = ">=0.111.0" }, { name = "fastapi", specifier = ">=0.111.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "langchain", specifier = ">=0.2.0" }, { name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-anthropic", specifier = ">=0.1.0" }, { name = "langchain-anthropic", specifier = ">=0.1.0" },
{ name = "langchain-openai", specifier = ">=0.1.0" }, { name = "langchain-openai", specifier = ">=0.1.0" },
{ name = "passlib", specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic", specifier = ">=2.7.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.7.0" },
{ name = "pydantic-settings", specifier = ">=2.2.0" }, { name = "pydantic-settings", specifier = ">=2.2.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
] ]
......
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ThemeProvider, CssBaseline } from '@mui/material' import { ThemeProvider, CssBaseline } from '@mui/material'
import { medicalTheme } from './theme/medicalTheme' import { medicalTheme } from './theme/medicalTheme'
import { AuthProvider } from './contexts/AuthContext'
import { PrivateRoute } from './components/auth/PrivateRoute'
import { AppShell } from './components/layout/AppShell' import { AppShell } from './components/layout/AppShell'
import { LoginPage } from './pages/LoginPage'
import { PatientsPage } from './pages/PatientsPage' import { PatientsPage } from './pages/PatientsPage'
import { TrialsPage } from './pages/TrialsPage' import { TrialsPage } from './pages/TrialsPage'
import { MatchingPage } from './pages/MatchingPage' import { MatchingPage } from './pages/MatchingPage'
import { WorkStationPage } from './pages/WorkStationPage' import { WorkStationPage } from './pages/WorkStationPage'
import { DiagnosesPage } from './pages/DiagnosesPage' import { DiagnosesPage } from './pages/DiagnosesPage'
import { SystemPage } from './pages/SystemPage'
export default function App() { export default function App() {
return ( return (
<ThemeProvider theme={medicalTheme}> <ThemeProvider theme={medicalTheme}>
<CssBaseline /> <CssBaseline />
<AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<AppShell />}> {/* 登录页面 - 公开访问 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由 - 需要登录 */}
<Route
path="/"
element={
<PrivateRoute>
<AppShell />
</PrivateRoute>
}
>
<Route index element={<Navigate to="/patients" replace />} /> <Route index element={<Navigate to="/patients" replace />} />
<Route path="patients" element={<PatientsPage />} />
<Route path="trials" element={<TrialsPage />} /> {/* 患者管理 */}
<Route path="diagnoses" element={<DiagnosesPage />} /> <Route
<Route path="matching" element={<MatchingPage />} /> path="patients"
<Route path="workstation" element={<WorkStationPage />} /> element={
<PrivateRoute requiredPermission="menu:patients">
<PatientsPage />
</PrivateRoute>
}
/>
{/* 试验管理 */}
<Route
path="trials"
element={
<PrivateRoute requiredPermission="menu:trials">
<TrialsPage />
</PrivateRoute>
}
/>
{/* 诊断管理 */}
<Route
path="diagnoses"
element={
<PrivateRoute requiredPermission="menu:diagnoses">
<DiagnosesPage />
</PrivateRoute>
}
/>
{/* 匹配管理 */}
<Route
path="matching"
element={
<PrivateRoute requiredPermission="menu:matching">
<MatchingPage />
</PrivateRoute>
}
/>
{/* 医生工作站 */}
<Route
path="workstation"
element={
<PrivateRoute requiredPermission="menu:workstation">
<WorkStationPage />
</PrivateRoute>
}
/>
{/* 系统管理 */}
<Route
path="system"
element={
<PrivateRoute requiredPermission="menu:system">
<SystemPage />
</PrivateRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider>
</ThemeProvider> </ThemeProvider>
) )
} }
import React from 'react'
import { Button, ButtonProps, Tooltip } from '@mui/material'
import { useAuth } from '../../contexts/AuthContext'
interface PermissionButtonProps extends ButtonProps {
permissionCode: string
showTooltip?: boolean
tooltipMessage?: string
}
/**
* 权限按钮组件 - 根据用户权限控制按钮的显示和禁用状态
* @param permissionCode - 所需权限编码
* @param showTooltip - 无权限时是否显示提示(默认 true)
* @param tooltipMessage - 提示消息(默认"权限不足")
* @param children - 按钮内容
* @param props - 其他 Button 属性
*/
export const PermissionButton: React.FC<PermissionButtonProps> = ({
permissionCode,
showTooltip = true,
tooltipMessage = '权限不足',
children,
...props
}) => {
const { hasPermission } = useAuth()
const allowed = hasPermission(permissionCode)
// 无权限时完全不显示按钮
if (!allowed && !showTooltip) {
return null
}
const button = (
<Button {...props} disabled={!allowed || props.disabled}>
{children}
</Button>
)
// 无权限时显示 tooltip
if (!allowed && showTooltip) {
return (
<Tooltip title={tooltipMessage} arrow>
<span>{button}</span>
</Tooltip>
)
}
return button
}
import React from 'react'
import { useAuth } from '../../contexts/AuthContext'
interface PermissionGuardProps {
children: React.ReactNode
permissionCode?: string
roleCode?: string
fallback?: React.ReactNode
}
/**
* 权限守卫组件 - 用于包裹需要权限控制的内容
* @param children - 有权限时显示的内容
* @param permissionCode - 所需权限编码
* @param roleCode - 所需角色编码
* @param fallback - 无权限时显示的内容(默认不显示)
*/
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
children,
permissionCode,
roleCode,
fallback = null,
}) => {
const { hasPermission, hasRole } = useAuth()
// 检查权限
if (permissionCode && !hasPermission(permissionCode)) {
return <>{fallback}</>
}
// 检查角色
if (roleCode && !hasRole(roleCode)) {
return <>{fallback}</>
}
return <>{children}</>
}
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext'
import { Box, CircularProgress } from '@mui/material'
interface PrivateRouteProps {
children: React.ReactNode
requiredPermission?: string
requiredRole?: string
}
/**
* 私���路由组件 - 用于保护需要认证的路由
* @param children - 子组件
* @param requiredPermission - 所需权限编码(可选)
* @param requiredRole - 所需角色编码(可选)
*/
export const PrivateRoute: React.FC<PrivateRouteProps> = ({
children,
requiredPermission,
requiredRole,
}) => {
const { isAuthenticated, isLoading, hasPermission, hasRole, user } = useAuth()
// 加载中显示 loading
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress />
</Box>
)
}
// 未登录,重定向到登录页
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
// 检查权限
if (requiredPermission && !hasPermission(requiredPermission)) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<h2>权限不足</h2>
<p>您没有访问此页面的权限</p>
</Box>
)
}
// 检查角色
if (requiredRole && !hasRole(requiredRole)) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<h2>权限不足</h2>
<p>您的角色无法访问此页面</p>
</Box>
)
}
return <>{children}</>
}
...@@ -8,13 +8,16 @@ import ScienceIcon from '@mui/icons-material/Science' ...@@ -8,13 +8,16 @@ import ScienceIcon from '@mui/icons-material/Science'
import PsychologyIcon from '@mui/icons-material/Psychology' import PsychologyIcon from '@mui/icons-material/Psychology'
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart' import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'
import LocalHospitalIcon from '@mui/icons-material/LocalHospital' import LocalHospitalIcon from '@mui/icons-material/LocalHospital'
import SettingsIcon from '@mui/icons-material/Settings'
import { useAuth } from '../../contexts/AuthContext'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ label: '患者管理', path: '/patients', icon: <PeopleIcon /> }, { label: '患者管理', path: '/patients', icon: <PeopleIcon />, permission: 'menu:patients' },
{ label: '临床试验', path: '/trials', icon: <ScienceIcon /> }, { label: '临床试验', path: '/trials', icon: <ScienceIcon />, permission: 'menu:trials' },
{ label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon /> }, { label: '诊断管理', path: '/diagnoses', icon: <LocalHospitalIcon />, permission: 'menu:diagnoses' },
{ label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon /> }, { label: 'AI 智能匹配', path: '/matching', icon: <PsychologyIcon />, permission: 'menu:matching' },
{ label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon /> }, { label: '医生工作站', path: '/workstation', icon: <MonitorHeartIcon />, permission: 'menu:workstation' },
{ label: '系统管理', path: '/system', icon: <SettingsIcon />, permission: 'menu:system' },
] ]
interface SidebarProps { interface SidebarProps {
...@@ -26,6 +29,10 @@ interface SidebarProps { ...@@ -26,6 +29,10 @@ interface SidebarProps {
export function Sidebar({ drawerWidth, mobileOpen, onClose }: SidebarProps) { export function Sidebar({ drawerWidth, mobileOpen, onClose }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { hasPermission } = useAuth()
// 根据权限过滤菜单项
const visibleItems = NAV_ITEMS.filter(item => hasPermission(item.permission))
const drawerContent = ( const drawerContent = (
<Box> <Box>
...@@ -36,7 +43,7 @@ export function Sidebar({ drawerWidth, mobileOpen, onClose }: SidebarProps) { ...@@ -36,7 +43,7 @@ export function Sidebar({ drawerWidth, mobileOpen, onClose }: SidebarProps) {
</Toolbar> </Toolbar>
<Divider /> <Divider />
<List> <List>
{NAV_ITEMS.map(item => ( {visibleItems.map(item => (
<ListItem key={item.path} disablePadding> <ListItem key={item.path} disablePadding>
<ListItemButton <ListItemButton
selected={location.pathname.startsWith(item.path)} selected={location.pathname.startsWith(item.path)}
......
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AppBar, Toolbar, Typography, IconButton, Badge, Box } from '@mui/material' import {
AppBar, Toolbar, Typography, IconButton, Badge, Box,
Menu, MenuItem, Avatar, Divider, ListItemIcon
} from '@mui/material'
import MenuIcon from '@mui/icons-material/Menu' import MenuIcon from '@mui/icons-material/Menu'
import NotificationsIcon from '@mui/icons-material/Notifications' import NotificationsIcon from '@mui/icons-material/Notifications'
import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import LogoutIcon from '@mui/icons-material/Logout'
import SettingsIcon from '@mui/icons-material/Settings'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { notificationService } from '../../services/notificationService' import { notificationService } from '../../services/notificationService'
import { useAuth } from '../../contexts/AuthContext'
interface TopBarProps { interface TopBarProps {
drawerWidth: number drawerWidth: number
...@@ -12,7 +19,9 @@ interface TopBarProps { ...@@ -12,7 +19,9 @@ interface TopBarProps {
export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) { export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth()
useEffect(() => { useEffect(() => {
const fetchCount = () => { const fetchCount = () => {
...@@ -25,6 +34,20 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) { ...@@ -25,6 +34,20 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleUserMenuClose = () => {
setAnchorEl(null)
}
const handleLogout = () => {
logout()
navigate('/login')
handleUserMenuClose()
}
return ( return (
<AppBar <AppBar
position="fixed" position="fixed"
...@@ -42,12 +65,57 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) { ...@@ -42,12 +65,57 @@ export function TopBar({ drawerWidth, onMenuClick }: TopBarProps) {
智能患者招募系统 智能患者招募系统
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ opacity: 0.8 }}>医疗蓝 v0.1</Typography>
<IconButton color="inherit" onClick={() => navigate('/workstation')}> <IconButton color="inherit" onClick={() => navigate('/workstation')}>
<Badge badgeContent={unreadCount} color="error"> <Badge badgeContent={unreadCount} color="error">
<NotificationsIcon /> <NotificationsIcon />
</Badge> </Badge>
</IconButton> </IconButton>
{/* 用户信息 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 2 }}>
<Box sx={{ display: { xs: 'none', md: 'block' }, textAlign: 'right' }}>
<Typography variant="body2" fontWeight={600}>
{user?.full_name}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
{user?.department?.name} · {user?.title || '医生'}
</Typography>
</Box>
<IconButton color="inherit" onClick={handleUserMenuOpen}>
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main' }}>
{user?.full_name?.charAt(0)}
</Avatar>
</IconButton>
</Box>
{/* 用户菜单 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleUserMenuClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem disabled>
<ListItemIcon>
<AccountCircleIcon fontSize="small" />
</ListItemIcon>
{user?.username}
</MenuItem>
<Divider />
<MenuItem onClick={() => { navigate('/system'); handleUserMenuClose() }}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
系统设置
</MenuItem>
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
退出登录
</MenuItem>
</Menu>
</Box> </Box>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
......
...@@ -5,6 +5,7 @@ import EditIcon from '@mui/icons-material/Edit' ...@@ -5,6 +5,7 @@ import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import type { PatientResponse, FHIRCondition } from '../../types/fhir' import type { PatientResponse, FHIRCondition } from '../../types/fhir'
import { StatusChip } from '../shared/StatusChip' import { StatusChip } from '../shared/StatusChip'
import { useAuth } from '../../contexts/AuthContext'
const genderMap: Record<string, string> = { male: '', female: '', other: '其他', unknown: '未知' } const genderMap: Record<string, string> = { male: '', female: '', other: '其他', unknown: '未知' }
...@@ -17,7 +18,11 @@ interface PatientDataGridProps { ...@@ -17,7 +18,11 @@ interface PatientDataGridProps {
} }
export function PatientDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: PatientDataGridProps) { export function PatientDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: PatientDataGridProps) {
const columns: GridColDef[] = [ const { hasPermission } = useAuth()
const canEdit = hasPermission('btn:patient:edit')
const canDelete = hasPermission('btn:patient:delete')
const baseColumns: GridColDef[] = [
{ field: 'mrn', headerName: '病历号', width: 140 }, { field: 'mrn', headerName: '病历号', width: 140 },
{ field: 'display_name', headerName: '患者标识', width: 150 }, { field: 'display_name', headerName: '患者标识', width: 150 },
{ field: 'age', headerName: '年龄', width: 70, type: 'number' }, { field: 'age', headerName: '年龄', width: 70, type: 'number' },
...@@ -35,29 +40,36 @@ export function PatientDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: ...@@ -35,29 +40,36 @@ export function PatientDataGrid({ rows, loading, onRowClick, onEdit, onDelete }:
</Box> </Box>
), ),
}, },
{ ]
const actionsColumn: GridColDef = {
field: 'actions', field: 'actions',
headerName: '操作', headerName: '操作',
width: 100, width: 100,
sortable: false, sortable: false,
renderCell: (params) => ( renderCell: (params) => (
<Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}>
{canEdit && (
<IconButton size="small" color="primary" onClick={(e) => { <IconButton size="small" color="primary" onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onEdit(params.row as PatientResponse); onEdit(params.row as PatientResponse)
}}> }}>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
)}
{canDelete && (
<IconButton size="small" color="error" onClick={(e) => { <IconButton size="small" color="error" onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onDelete(params.row.id); onDelete(params.row.id)
}}> }}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
)}
</Stack> </Stack>
), ),
} }
]
const columns = (canEdit || canDelete) ? [...baseColumns, actionsColumn] : baseColumns
const gridRows = useMemo(() => rows.map(r => ({ ...r, id: r.id })), [rows]) const gridRows = useMemo(() => rows.map(r => ({ ...r, id: r.id })), [rows])
......
import React, { useEffect, useState } from 'react'
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControlLabel,
Switch,
Alert,
Chip,
} from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import { systemService, Department } from '../../services/systemService'
export const DepartmentManagement: React.FC = () => {
const [departments, setDepartments] = useState<Department[]>([])
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingDept, setEditingDept] = useState<Department | null>(null)
const [error, setError] = useState('')
const [formData, setFormData] = useState({
name: '',
code: '',
description: '',
is_active: true,
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const data = await systemService.getDepartments()
setDepartments(data)
} catch (err: any) {
setError(err.response?.data?.detail || '加载数据失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingDept(null)
setFormData({
name: '',
code: '',
description: '',
is_active: true,
})
setDialogOpen(true)
}
const handleEdit = (dept: Department) => {
setEditingDept(dept)
setFormData({
name: dept.name,
code: dept.code || '',
description: dept.description || '',
is_active: dept.is_active,
})
setDialogOpen(true)
}
const handleSave = async () => {
try {
if (editingDept) {
await systemService.updateDepartment(editingDept.id, formData)
} else {
await systemService.createDepartment(formData)
}
setDialogOpen(false)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '保存失败')
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('确定要删除该科室吗?')) return
try {
await systemService.deleteDepartment(id)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '删除失败')
}
}
const columns: GridColDef[] = [
{ field: 'name', headerName: '科室名称', width: 200 },
{ field: 'code', headerName: '科室编码', width: 150 },
{ field: 'description', headerName: '描述', width: 300 },
{
field: 'is_active',
headerName: '状态',
width: 100,
renderCell: (params) => (
<Chip
label={params.value ? '启用' : '禁用'}
color={params.value ? 'success' : 'default'}
size="small"
/>
),
},
{
field: 'actions',
headerName: '操作',
width: 200,
renderCell: (params) => (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => handleEdit(params.row)}
>
编辑
</Button>
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleDelete(params.row.id)}
>
删除
</Button>
</Box>
),
},
]
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box sx={{ mb: 2 }}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
>
添加科室
</Button>
</Box>
<DataGrid
rows={departments}
columns={columns}
loading={loading}
autoHeight
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 10 } },
}}
/>
{/* 添加/编辑对话框 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingDept ? '编辑科室' : '添加科室'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="科室名称"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<TextField
label="科室编码"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
<TextField
label="描述"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
multiline
rows={3}
/>
<FormControlLabel
control={
<Switch
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
}
label="启用科室"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSave} variant="contained">
保存
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
import React from 'react'
import {
Box,
Checkbox,
FormControlLabel,
Typography,
Collapse,
IconButton,
} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import { Permission } from '../../services/systemService'
interface TreeNode {
permission: Permission
children: TreeNode[]
}
interface PermissionTreeProps {
permissions: Permission[]
selectedIds: string[]
onChange: (selectedIds: string[]) => void
}
export const PermissionTree: React.FC<PermissionTreeProps> = ({
permissions,
selectedIds,
onChange,
}) => {
const [expanded, setExpanded] = React.useState<Set<string>>(new Set())
// 构建树形结构
const buildTree = (): TreeNode[] => {
// 菜单权限作为根节点
const menuPermissions = permissions.filter(p => p.type === 'menu' && !p.parent_id)
const buildNode = (perm: Permission): TreeNode => {
// 查找子菜单
const childMenus = permissions.filter(p => p.type === 'menu' && p.parent_id === perm.id)
// 查找按钮权限
const buttons = permissions.filter(p => p.type === 'button' && p.menu_id === perm.code)
const children = [
...childMenus.map(buildNode),
...buttons.map(b => ({ permission: b, children: [] }))
]
return { permission: perm, children }
}
return menuPermissions.map(buildNode)
}
const tree = buildTree()
const handleToggle = (permId: string) => {
const newExpanded = new Set(expanded)
if (newExpanded.has(permId)) {
newExpanded.delete(permId)
} else {
newExpanded.add(permId)
}
setExpanded(newExpanded)
}
const handleCheck = (permId: string, node: TreeNode) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(permId)) {
// 取消选中:移除当前节点及所有子节点
newSelected.delete(permId)
const removeChildren = (n: TreeNode) => {
newSelected.delete(n.permission.id)
n.children.forEach(removeChildren)
}
node.children.forEach(removeChildren)
} else {
// 选中:添加当前节点及所有子节点
newSelected.add(permId)
const addChildren = (n: TreeNode) => {
newSelected.add(n.permission.id)
n.children.forEach(addChildren)
}
node.children.forEach(addChildren)
}
onChange(Array.from(newSelected))
}
const isIndeterminate = (node: TreeNode): boolean => {
if (node.children.length === 0) return false
const childIds = new Set<string>()
const collectIds = (n: TreeNode) => {
childIds.add(n.permission.id)
n.children.forEach(collectIds)
}
node.children.forEach(collectIds)
const selectedChildCount = Array.from(childIds).filter(id => selectedIds.includes(id)).length
return selectedChildCount > 0 && selectedChildCount < childIds.size
}
const renderNode = (node: TreeNode, level: number = 0) => {
const { permission, children } = node
const hasChildren = children.length > 0
const isExpanded = expanded.has(permission.id)
const isChecked = selectedIds.includes(permission.id)
const indeterminate = isIndeterminate(node)
return (
<Box key={permission.id}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
pl: level * 3,
py: 0.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
{hasChildren ? (
<IconButton
size="small"
onClick={() => handleToggle(permission.id)}
sx={{ mr: 0.5 }}
>
{isExpanded ? <ExpandMoreIcon /> : <ChevronRightIcon />}
</IconButton>
) : (
<Box sx={{ width: 32, mr: 0.5 }} />
)}
<FormControlLabel
control={
<Checkbox
checked={isChecked}
indeterminate={indeterminate}
onChange={() => handleCheck(permission.id, node)}
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" fontWeight={permission.type === 'menu' ? 600 : 400}>
{permission.name}
</Typography>
<Typography variant="caption" color="text.secondary">
({permission.code})
</Typography>
</Box>
}
sx={{ flex: 1, m: 0 }}
/>
</Box>
{hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box>
{children.map(child => renderNode(child, level + 1))}
</Box>
</Collapse>
)}
</Box>
)
}
return (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
maxHeight: 400,
overflow: 'auto',
p: 1,
}}
>
{tree.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2, textAlign: 'center' }}>
暂无权限数据
</Typography>
) : (
tree.map(node => renderNode(node))
)}
</Box>
)
}
import React, { useEffect, useState } from 'react'
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControlLabel,
Switch,
Alert,
Chip,
Typography,
} from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import { systemService, Role, RoleCreate, Permission } from '../../services/systemService'
import { PermissionTree } from './PermissionTree'
export const RoleManagement: React.FC = () => {
const [roles, setRoles] = useState<Role[]>([])
const [permissions, setPermissions] = useState<Permission[]>([])
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingRole, setEditingRole] = useState<Role | null>(null)
const [error, setError] = useState('')
const [formData, setFormData] = useState<RoleCreate>({
name: '',
code: '',
description: '',
is_active: true,
permission_ids: [],
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [rolesData, permsData] = await Promise.all([
systemService.getRoles(),
systemService.getPermissions(),
])
setRoles(rolesData)
setPermissions(permsData)
} catch (err: any) {
setError(err.response?.data?.detail || '加载数据失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingRole(null)
setFormData({
name: '',
code: '',
description: '',
is_active: true,
permission_ids: [],
})
setDialogOpen(true)
}
const handleEdit = async (role: Role) => {
try {
const roleWithPerms = await systemService.getRoleWithPermissions(role.id)
setEditingRole(role)
setFormData({
name: role.name,
code: role.code,
description: role.description || '',
is_active: role.is_active,
permission_ids: roleWithPerms.permissions.map((p: Permission) => p.id),
})
setDialogOpen(true)
} catch (err: any) {
setError(err.response?.data?.detail || '加载角色详情失败')
}
}
const handleSave = async () => {
try {
if (editingRole) {
await systemService.updateRole(editingRole.id, formData)
} else {
await systemService.createRole(formData)
}
setDialogOpen(false)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '保存失败')
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('确定要删除该角色吗?')) return
try {
await systemService.deleteRole(id)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '删除失败')
}
}
const columns: GridColDef[] = [
{ field: 'name', headerName: '角色名称', width: 150 },
{ field: 'code', headerName: '角色编码', width: 150 },
{ field: 'description', headerName: '描述', width: 300 },
{
field: 'is_active',
headerName: '状态',
width: 100,
renderCell: (params) => (
<Chip
label={params.value ? '启用' : '禁用'}
color={params.value ? 'success' : 'default'}
size="small"
/>
),
},
{
field: 'actions',
headerName: '操作',
width: 200,
renderCell: (params) => (
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => handleEdit(params.row)}
>
编辑
</Button>
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleDelete(params.row.id)}
>
删除
</Button>
</Box>
),
},
]
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box sx={{ mb: 2 }}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
>
添加角色
</Button>
</Box>
<DataGrid
rows={roles}
columns={columns}
loading={loading}
autoHeight
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 10 } },
}}
/>
{/* 添加/编辑对话框 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingRole ? '编辑角色' : '添加角色'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="角色名称"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<TextField
label="角色编码"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
disabled={!!editingRole}
required
/>
<TextField
label="描述"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
multiline
rows={3}
/>
<Box>
<Typography variant="subtitle2" gutterBottom>
权限配置
</Typography>
<PermissionTree
permissions={permissions}
selectedIds={formData.permission_ids || []}
onChange={(ids) => setFormData({ ...formData, permission_ids: ids })}
/>
</Box>
<FormControlLabel
control={
<Switch
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
}
label="启用角色"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSave} variant="contained">
保存
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
import React, { useEffect, useState } from 'react'
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControlLabel,
Switch,
Alert,
Select,
MenuItem,
FormControl,
InputLabel,
Chip,
} from '@mui/material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import LockResetIcon from '@mui/icons-material/LockReset'
import { systemService, User, UserCreate, Department, Role } from '../../services/systemService'
import { PermissionButton } from '../auth/PermissionButton'
export const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([])
const [departments, setDepartments] = useState<Department[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [error, setError] = useState('')
const [formData, setFormData] = useState<UserCreate>({
username: '',
password: '',
full_name: '',
email: '',
phone: '',
title: '',
department_id: '',
is_active: true,
role_ids: [],
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [usersData, deptsData, rolesData] = await Promise.all([
systemService.getUsers(),
systemService.getDepartments(),
systemService.getRoles(),
])
setUsers(usersData)
setDepartments(deptsData)
setRoles(rolesData)
} catch (err: any) {
setError(err.response?.data?.detail || '加载数据失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingUser(null)
setFormData({
username: '',
password: '',
full_name: '',
email: '',
phone: '',
title: '',
department_id: '',
is_active: true,
role_ids: [],
})
setDialogOpen(true)
}
const handleEdit = async (user: User) => {
try {
// 获取用户详情(包含角色)
const userDetail = await systemService.getUser(user.id)
setEditingUser(user)
setFormData({
username: user.username,
password: '', // 编辑时不填密码
full_name: user.full_name,
email: user.email || '',
phone: user.phone || '',
title: user.title || '',
department_id: user.department_id || '',
is_active: user.is_active,
role_ids: userDetail.roles?.map((r: any) => r.id) || [],
})
setDialogOpen(true)
} catch (err: any) {
setError(err.response?.data?.detail || '加载用户详情失败')
}
}
const handleSave = async () => {
try {
if (editingUser) {
const updateData = { ...formData }
delete (updateData as any).password // 编辑时不更新密码
delete (updateData as any).username
await systemService.updateUser(editingUser.id, updateData)
} else {
await systemService.createUser(formData)
}
setDialogOpen(false)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '保存失败')
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('确定要删除该用户吗?')) return
try {
await systemService.deleteUser(id)
loadData()
} catch (err: any) {
setError(err.response?.data?.detail || '删除失败')
}
}
const columns: GridColDef[] = [
{ field: 'username', headerName: '用户名', width: 120 },
{ field: 'full_name', headerName: '姓名', width: 120 },
{ field: 'email', headerName: '邮箱', width: 180 },
{ field: 'phone', headerName: '电话', width: 130 },
{ field: 'title', headerName: '职称', width: 120 },
{
field: 'is_active',
headerName: '状态',
width: 100,
renderCell: (params) => (
<Chip
label={params.value ? '启用' : '禁用'}
color={params.value ? 'success' : 'default'}
size="small"
/>
),
},
{
field: 'actions',
headerName: '操作',
width: 200,
renderCell: (params) => (
<Box sx={{ display: 'flex', gap: 1 }}>
<PermissionButton
permissionCode="btn:user:edit"
size="small"
startIcon={<EditIcon />}
onClick={() => handleEdit(params.row)}
>
编辑
</PermissionButton>
<PermissionButton
permissionCode="btn:user:delete"
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => handleDelete(params.row.id)}
>
删除
</PermissionButton>
</Box>
),
},
]
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box sx={{ mb: 2 }}>
<PermissionButton
permissionCode="btn:user:add"
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
>
添加用户
</PermissionButton>
</Box>
<DataGrid
rows={users}
columns={columns}
loading={loading}
autoHeight
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: { paginationModel: { pageSize: 10 } },
}}
/>
{/* 添加/编辑对话框 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingUser ? '编辑用户' : '添加用户'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="用户名"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
disabled={!!editingUser}
required
/>
{!editingUser && (
<TextField
label="密码"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
)}
<TextField
label="姓名"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
required
/>
<TextField
label="邮箱"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<TextField
label="电话"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<TextField
label="职称"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
<FormControl>
<InputLabel>科室</InputLabel>
<Select
value={formData.department_id}
onChange={(e) => setFormData({ ...formData, department_id: e.target.value })}
>
{departments.map((dept) => (
<MenuItem key={dept.id} value={dept.id}>
{dept.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl>
<InputLabel>角色</InputLabel>
<Select
multiple
value={formData.role_ids || []}
onChange={(e) => setFormData({ ...formData, role_ids: e.target.value as string[] })}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const role = roles.find(r => r.id === value)
return <Chip key={value} label={role?.name} size="small" />
})}
</Box>
)}
>
{roles.map((role) => (
<MenuItem key={role.id} value={role.id}>
{role.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
/>
}
label="启用账号"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSave} variant="contained">
保存
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
...@@ -4,6 +4,7 @@ import EditIcon from '@mui/icons-material/Edit' ...@@ -4,6 +4,7 @@ import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import type { TrialResponse } from '../../types/trial' import type { TrialResponse } from '../../types/trial'
import { StatusChip } from '../shared/StatusChip' import { StatusChip } from '../shared/StatusChip'
import { useAuth } from '../../contexts/AuthContext'
const phaseColor: Record<string, 'default' | 'primary' | 'secondary' | 'warning'> = { const phaseColor: Record<string, 'default' | 'primary' | 'secondary' | 'warning'> = {
I: 'default', II: 'primary', III: 'secondary', IV: 'warning', I: 'default', II: 'primary', III: 'secondary', IV: 'warning',
...@@ -18,7 +19,11 @@ interface TrialDataGridProps { ...@@ -18,7 +19,11 @@ interface TrialDataGridProps {
} }
export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: TrialDataGridProps) { export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: TrialDataGridProps) {
const columns: GridColDef[] = [ const { hasPermission } = useAuth()
const canEdit = hasPermission('btn:trial:edit')
const canDelete = hasPermission('btn:trial:delete')
const baseColumns: GridColDef[] = [
{ field: 'nct_number', headerName: 'NCT编号', width: 130 }, { field: 'nct_number', headerName: 'NCT编号', width: 130 },
{ field: 'title', headerName: '试验名称', flex: 1, minWidth: 200 }, { field: 'title', headerName: '试验名称', flex: 1, minWidth: 200 },
{ field: 'sponsor', headerName: '申办方', width: 140 }, { field: 'sponsor', headerName: '申办方', width: 140 },
...@@ -34,29 +39,36 @@ export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: T ...@@ -34,29 +39,36 @@ export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: T
field: 'criteria', headerName: '标准数', width: 90, type: 'number', field: 'criteria', headerName: '标准数', width: 90, type: 'number',
valueGetter: (v: unknown[]) => v?.length ?? 0, valueGetter: (v: unknown[]) => v?.length ?? 0,
}, },
{ ]
const actionsColumn: GridColDef = {
field: 'actions', field: 'actions',
headerName: '操作', headerName: '操作',
width: 100, width: 100,
sortable: false, sortable: false,
renderCell: (params) => ( renderCell: (params) => (
<Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}>
{canEdit && (
<IconButton size="small" color="primary" onClick={(e) => { <IconButton size="small" color="primary" onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onEdit(params.row as TrialResponse); onEdit(params.row as TrialResponse)
}}> }}>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
)}
{canDelete && (
<IconButton size="small" color="error" onClick={(e) => { <IconButton size="small" color="error" onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onDelete(params.row.id); onDelete(params.row.id)
}}> }}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
)}
</Stack> </Stack>
), ),
} }
]
const columns = (canEdit || canDelete) ? [...baseColumns, actionsColumn] : baseColumns
return ( return (
<DataGrid <DataGrid
......
import React, { createContext, useContext, useState, useEffect } from 'react'
import { authService, UserInfo, LoginRequest } from '../services/authService'
interface AuthContextType {
user: UserInfo | null
isAuthenticated: boolean
isLoading: boolean
login: (credentials: LoginRequest) => Promise<void>
logout: () => void
hasPermission: (permissionCode: string) => boolean
hasRole: (roleCode: string) => boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<UserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
// 初始化时从 localStorage 加载用户信息
useEffect(() => {
const currentUser = authService.getCurrentUser()
setUser(currentUser)
setIsLoading(false)
}, [])
const login = async (credentials: LoginRequest) => {
const response = await authService.login(credentials)
setUser(response.user)
}
const logout = () => {
authService.logout()
setUser(null)
}
const hasPermission = (permissionCode: string): boolean => {
if (!user) return false
if (user.is_superuser) return true
return user.permissions.some(p => p.code === permissionCode)
}
const hasRole = (roleCode: string): boolean => {
if (!user) return false
return user.roles.some(r => r.code === roleCode)
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
hasPermission,
hasRole,
}}
>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth 必须在 AuthProvider 内部使用')
}
return context
}
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
Box, Typography, Button, Card, CardContent, Stack, TextField, Box, Typography, Card, CardContent, Stack, TextField,
IconButton, Dialog, DialogTitle, DialogContent, DialogActions IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Button
} from '@mui/material' } from '@mui/material'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import { DataGrid, GridColDef } from '@mui/x-data-grid' import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { diagnosisService, Diagnosis } from '../services/diagnosisService' import { diagnosisService, Diagnosis } from '../services/diagnosisService'
import { useAuth } from '../contexts/AuthContext'
import { PermissionButton } from '../components/auth/PermissionButton'
export function DiagnosesPage() { export function DiagnosesPage() {
const { hasPermission } = useAuth()
const canAdd = hasPermission('btn:diagnosis:add')
const canEdit = hasPermission('btn:diagnosis:edit')
const canDelete = hasPermission('btn:diagnosis:delete')
const [items, setItems] = useState<Diagnosis[]>([]) const [items, setItems] = useState<Diagnosis[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
...@@ -59,19 +66,23 @@ export function DiagnosesPage() { ...@@ -59,19 +66,23 @@ export function DiagnosesPage() {
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ field: 'description', headerName: '诊断描述', flex: 1 }, { field: 'description', headerName: '诊断描述', flex: 1 },
{ field: 'icd10_code', headerName: 'ICD-10 编码', width: 200 }, { field: 'icd10_code', headerName: 'ICD-10 编码', width: 200 },
{ ...(canEdit || canDelete ? [{
field: 'actions', headerName: '操作', width: 120, sortable: false, field: 'actions', headerName: '操作', width: 120, sortable: false,
renderCell: (params) => ( renderCell: (params: any) => (
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{canEdit && (
<IconButton size="small" color="primary" onClick={() => handleOpen(params.row as Diagnosis)}> <IconButton size="small" color="primary" onClick={() => handleOpen(params.row as Diagnosis)}>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
)}
{canDelete && (
<IconButton size="small" color="error" onClick={() => handleDelete(params.row.id)}> <IconButton size="small" color="error" onClick={() => handleDelete(params.row.id)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
)}
</Stack> </Stack>
) )
} }] : []),
] ]
return ( return (
...@@ -81,9 +92,15 @@ export function DiagnosesPage() { ...@@ -81,9 +92,15 @@ export function DiagnosesPage() {
<Typography variant="h5">诊断管理</Typography> <Typography variant="h5">诊断管理</Typography>
<Typography variant="subtitle2">维护系统标准诊断库 (ICD-10)</Typography> <Typography variant="subtitle2">维护系统标准诊断库 (ICD-10)</Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpen()}> <PermissionButton
permissionCode="btn:diagnosis:add"
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpen()}
showTooltip={false}
>
新增标准诊断 新增标准诊断
</Button> </PermissionButton>
</Stack> </Stack>
<Card> <Card>
......
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Card,
CardContent,
TextField,
Button,
Typography,
Alert,
CircularProgress,
InputAdornment,
IconButton,
} from '@mui/material'
import { Visibility, VisibilityOff, LocalHospital } from '@mui/icons-material'
import { useAuth } from '../contexts/AuthContext'
export const LoginPage: React.FC = () => {
const navigate = useNavigate()
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!username || !password) {
setError('请输入用户名和密码')
return
}
setLoading(true)
try {
await login({ username, password })
navigate('/patients')
} catch (err: any) {
setError(err.response?.data?.detail || '登录失败,请检查用户名和密码')
} finally {
setLoading(false)
}
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1976d2 0%, #2196f3 100%)',
padding: 2,
}}
>
<Card
sx={{
maxWidth: 450,
width: '100%',
boxShadow: '0 8px 32px rgba(0,0,0,0.1)',
}}
>
<CardContent sx={{ p: 4 }}>
{/* 标题区域 */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<LocalHospital
sx={{
fontSize: 60,
color: 'primary.main',
mb: 2,
}}
/>
<Typography variant="h4" fontWeight="bold" color="primary.main" gutterBottom>
智能患者招募系统
</Typography>
<Typography variant="body2" color="text.secondary">
Smart Recruitment System
</Typography>
</Box>
{/* 错误提示 */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* 登录表单 */}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
autoComplete="username"
sx={{ mb: 2 }}
/>
<TextField
fullWidth
label="密码"
type={showPassword ? 'text' : 'password'}
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{ mb: 3 }}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading}
sx={{
py: 1.5,
fontSize: '1rem',
fontWeight: 'bold',
}}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} color="inherit" />
登录中...
</>
) : (
'登录'
)}
</Button>
</form>
{/* 底部提示 */}
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="caption" color="text.secondary">
默认账号: admin / admin123
</Typography>
</Box>
</CardContent>
</Card>
</Box>
)
}
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
Box, Typography, Card, CardContent, CardHeader, Grid, Box, Typography, Card, CardContent, CardHeader, Grid,
FormControl, InputLabel, Select, MenuItem, Button, Stack, FormControl, InputLabel, Select, MenuItem, Stack,
Tabs, Tab, Alert, Divider, Tabs, Tab, Alert, Divider,
} from '@mui/material' } from '@mui/material'
import { PermissionButton } from '../components/auth/PermissionButton'
import PsychologyIcon from '@mui/icons-material/Psychology' import PsychologyIcon from '@mui/icons-material/Psychology'
import { MatchingStepper } from '../components/matching/MatchingStepper' import { MatchingStepper } from '../components/matching/MatchingStepper'
import { MatchingResultCard } from '../components/matching/MatchingResultCard' import { MatchingResultCard } from '../components/matching/MatchingResultCard'
...@@ -110,14 +111,16 @@ export function MatchingPage() { ...@@ -110,14 +111,16 @@ export function MatchingPage() {
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} sm={3}> <Grid item xs={12} sm={3}>
<Button <PermissionButton
permissionCode="btn:matching:execute"
fullWidth variant="contained" size="medium" fullWidth variant="contained" size="medium"
startIcon={<PsychologyIcon />} startIcon={<PsychologyIcon />}
onClick={handleRun} onClick={handleRun}
disabled={!selectedPatient || !selectedTrial || running} disabled={!selectedPatient || !selectedTrial || running}
showTooltip={false}
> >
{running ? 'AI 分析中...' : '开始匹配'} {running ? 'AI 分析中...' : '开始匹配'}
</Button> </PermissionButton>
</Grid> </Grid>
</Grid> </Grid>
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
......
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Box, Typography, Button, Card, CardContent, Stack, TextField, InputAdornment } from '@mui/material' import { Box, Typography, Card, CardContent, Stack, TextField, InputAdornment } from '@mui/material'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import SearchIcon from '@mui/icons-material/Search' import SearchIcon from '@mui/icons-material/Search'
import { PermissionButton } from '../components/auth/PermissionButton'
import { PatientDataGrid } from '../components/patients/PatientDataGrid' import { PatientDataGrid } from '../components/patients/PatientDataGrid'
import { PatientDetailDrawer } from '../components/patients/PatientDetailDrawer' import { PatientDetailDrawer } from '../components/patients/PatientDetailDrawer'
import { AddPatientDialog } from '../components/patients/AddPatientDialog' import { AddPatientDialog } from '../components/patients/AddPatientDialog'
...@@ -80,9 +81,15 @@ export function PatientsPage() { ...@@ -80,9 +81,15 @@ export function PatientsPage() {
<Typography variant="h5">患者管理</Typography> <Typography variant="h5">患者管理</Typography>
<Typography variant="subtitle2">{patients.length} 名患者</Typography> <Typography variant="subtitle2">{patients.length} 名患者</Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddNew}> <PermissionButton
permissionCode="btn:patient:add"
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddNew}
showTooltip={false}
>
新增患者登记 新增患者登记
</Button> </PermissionButton>
</Stack> </Stack>
<Card> <Card>
......
import React, { useState } from 'react'
import {
Box,
Paper,
Tabs,
Tab,
Typography,
} from '@mui/material'
import { UserManagement } from '../components/system/UserManagement'
import { RoleManagement } from '../components/system/RoleManagement'
import { DepartmentManagement } from '../components/system/DepartmentManagement'
interface TabPanelProps {
children?: React.ReactNode
index: number
value: number
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props
return (
<div
role="tabpanel"
hidden={value !== index}
id={`system-tabpanel-${index}`}
aria-labelledby={`system-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
)
}
export const SystemPage: React.FC = () => {
const [tabValue, setTabValue] = useState(0)
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue)
}
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" fontWeight="bold" color="primary.main" gutterBottom>
系统管理
</Typography>
<Paper sx={{ mt: 3 }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
sx={{
borderBottom: 1,
borderColor: 'divider',
'& .MuiTab-root': { fontWeight: 600 },
}}
>
<Tab label="用户管理" />
<Tab label="角色管理" />
<Tab label="科室管理" />
</Tabs>
<TabPanel value={tabValue} index={0}>
<UserManagement />
</TabPanel>
<TabPanel value={tabValue} index={1}>
<RoleManagement />
</TabPanel>
<TabPanel value={tabValue} index={2}>
<DepartmentManagement />
</TabPanel>
</Paper>
</Box>
)
}
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
Box, Typography, Button, Card, CardContent, CardHeader, Box, Typography, Card, CardContent, CardHeader,
Stack, Chip, Collapse, IconButton, Divider, TextField, InputAdornment, Stack, Chip, Collapse, IconButton, Divider, TextField, InputAdornment,
} from '@mui/material' } from '@mui/material'
import { PermissionButton } from '../components/auth/PermissionButton'
import AddIcon from '@mui/icons-material/Add' import AddIcon from '@mui/icons-material/Add'
import SearchIcon from '@mui/icons-material/Search' import SearchIcon from '@mui/icons-material/Search'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
...@@ -96,9 +97,15 @@ export function TrialsPage() { ...@@ -96,9 +97,15 @@ export function TrialsPage() {
<Typography variant="h5">临床试验管理</Typography> <Typography variant="h5">临床试验管理</Typography>
<Typography variant="subtitle2">{trials.length} 个试验项目</Typography> <Typography variant="subtitle2">{trials.length} 个试验项目</Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddNew}> <PermissionButton
permissionCode="btn:trial:add"
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddNew}
showTooltip={false}
>
发布新试验项目 发布新试验项目
</Button> </PermissionButton>
</Stack> </Stack>
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
......
...@@ -5,4 +5,32 @@ const api = axios.create({ ...@@ -5,4 +5,32 @@ const api = axios.create({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
// 请求拦截器:自动添加 token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器:处理 401 错误
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 清除 token 并跳转到登录页
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api export default api
import api from './api'
export interface LoginRequest {
username: string
password: string
}
export interface PermissionInfo {
id: string
code: string
name: string
type: string
path?: string
icon?: string
parent_id?: string
menu_id?: string
}
export interface RoleInfo {
id: string
name: string
code: string
description?: string
}
export interface DepartmentInfo {
id: string
name: string
code?: string
}
export interface UserInfo {
id: string
username: string
full_name: string
email?: string
phone?: string
title?: string
is_active: boolean
is_superuser: boolean
department?: DepartmentInfo
roles: RoleInfo[]
permissions: PermissionInfo[]
last_login?: string
created_at: string
}
export interface LoginResponse {
access_token: string
token_type: string
user: UserInfo
}
class AuthService {
/**
* 登录
*/
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/auth/login', credentials)
const { access_token, user } = response.data
// 保存 token 和用户信息到 localStorage
localStorage.setItem('access_token', access_token)
localStorage.setItem('user_info', JSON.stringify(user))
return response.data
}
/**
* 登出
*/
logout() {
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
}
/**
* 获取当前用户信息
*/
getCurrentUser(): UserInfo | null {
const userStr = localStorage.getItem('user_info')
if (!userStr) return null
try {
return JSON.parse(userStr)
} catch {
return null
}
}
/**
* 检查是否已登录
*/
isAuthenticated(): boolean {
return !!localStorage.getItem('access_token')
}
/**
* 检查用户是否拥有某个权限
*/
hasPermission(permissionCode: string): boolean {
const user = this.getCurrentUser()
if (!user) return false
// 超级管理员拥有所有权限
if (user.is_superuser) return true
// 检查权限列表
return user.permissions.some(p => p.code === permissionCode)
}
/**
* 检查用户是否拥有某个角色
*/
hasRole(roleCode: string): boolean {
const user = this.getCurrentUser()
if (!user) return false
return user.roles.some(r => r.code === roleCode)
}
/**
* 获取用户的菜单权限
*/
getMenuPermissions(): PermissionInfo[] {
const user = this.getCurrentUser()
if (!user) return []
return user.permissions.filter(p => p.type === 'menu')
}
}
export const authService = new AuthService()
import api from './api'
// 用户管理
export interface User {
id: string
username: string
full_name: string
email?: string
phone?: string
title?: string
department_id?: string
is_active: boolean
is_superuser: boolean
created_at: string
updated_at: string
}
export interface UserCreate {
username: string
password: string
full_name: string
email?: string
phone?: string
title?: string
department_id?: string
is_active?: boolean
role_ids?: string[]
}
// 角色管理
export interface Role {
id: string
name: string
code: string
description?: string
is_active: boolean
created_at: string
updated_at: string
}
export interface RoleCreate {
name: string
code: string
description?: string
is_active?: boolean
permission_ids?: string[]
}
// 权限管理
export interface Permission {
id: string
name: string
code: string
type: 'menu' | 'button'
path?: string
icon?: string
parent_id?: string
menu_id?: string
sort_order: string
description?: string
is_active: boolean
created_at: string
updated_at: string
}
// 科室管理
export interface Department {
id: string
name: string
code?: string
description?: string
is_active: boolean
created_at: string
updated_at: string
}
class SystemService {
// 用户管理
async getUsers() {
const response = await api.get<User[]>('/users')
return response.data
}
async getUser(id: string) {
const response = await api.get(`/users/${id}`)
return response.data
}
async createUser(data: UserCreate) {
const response = await api.post<User>('/users', data)
return response.data
}
async updateUser(id: string, data: Partial<UserCreate>) {
const response = await api.put<User>(`/users/${id}`, data)
return response.data
}
async deleteUser(id: string) {
await api.delete(`/users/${id}`)
}
async resetPassword(id: string, newPassword: string) {
await api.post(`/users/${id}/reset-password`, { new_password: newPassword })
}
// 角色管理
async getRoles() {
const response = await api.get<Role[]>('/roles')
return response.data
}
async createRole(data: RoleCreate) {
const response = await api.post<Role>('/roles', data)
return response.data
}
async updateRole(id: string, data: Partial<RoleCreate>) {
const response = await api.put<Role>(`/roles/${id}`, data)
return response.data
}
async deleteRole(id: string) {
await api.delete(`/roles/${id}`)
}
async getRoleWithPermissions(id: string) {
const response = await api.get(`/roles/${id}`)
return response.data
}
// 权限管理
async getPermissions() {
const response = await api.get<Permission[]>('/permissions')
return response.data
}
async createPermission(data: Partial<Permission>) {
const response = await api.post<Permission>('/permissions', data)
return response.data
}
async updatePermission(id: string, data: Partial<Permission>) {
const response = await api.put<Permission>(`/permissions/${id}`, data)
return response.data
}
async deletePermission(id: string) {
await api.delete(`/permissions/${id}`)
}
// 科室管理
async getDepartments() {
const response = await api.get<Department[]>('/departments')
return response.data
}
async createDepartment(data: Partial<Department>) {
const response = await api.post<Department>('/departments', data)
return response.data
}
async updateDepartment(id: string, data: Partial<Department>) {
const response = await api.put<Department>(`/departments/${id}`, data)
return response.data
}
async deleteDepartment(id: string) {
await api.delete(`/departments/${id}`)
}
}
export const systemService = new SystemService()
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment