新增用户注册功能
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
# CTMS 项目快速上手
|
||||
|
||||
## 账号(示例)
|
||||
- ADMIN:`admin / 123456`
|
||||
- PM:`pm1 / 123456`
|
||||
- CRA:`cra1 / 123456`
|
||||
|
||||
> 如需更多账号,可在前端 `/admin/users` 由 ADMIN 创建,并在项目成员中赋予角色。
|
||||
## 账号与注册
|
||||
- 初始化管理员:`admin@example.com / admin123`(已修正邮箱域名,后端启动时自动创建)
|
||||
- 自助注册:前端 `/register` 提交邮箱、密码、姓名、角色(CRA/PV/IMP/PM)、部门,状态为 PENDING。
|
||||
- 管理员审核:`/admin/user-approval` 列表查看待审核,支持通过/拒绝;仅 ACTIVE 用户可登录。
|
||||
- 更多账号:管理员也可在 `/admin/users` 直接创建 ACTIVE 账号,再在项目成员中赋予角色。
|
||||
|
||||
## 角色权限概要(前端操作级提示,后端仍最终裁决)
|
||||
- ADMIN:全权限
|
||||
@@ -20,7 +19,7 @@
|
||||
- 后端 API:同域 `/api/v1/*`(已在前端代理)
|
||||
|
||||
## 常用流程
|
||||
1. 用 `admin / Password123!` 登录前端。
|
||||
1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。
|
||||
2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。
|
||||
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
|
||||
4. 退出登录或切换项目可通过顶部右侧下拉。
|
||||
@@ -33,5 +32,6 @@
|
||||
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
|
||||
|
||||
## 注意
|
||||
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
|
||||
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
|
||||
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""create users table with review status"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20240501_000001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
user_role_enum = sa.Enum("ADMIN", "PM", "CRA", "PV", "IMP", name="user_role")
|
||||
user_status_enum = sa.Enum("PENDING", "ACTIVE", "REJECTED", "DISABLED", name="user_status")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
user_role_enum.create(bind, checkfirst=True)
|
||||
user_status_enum.create(bind, checkfirst=True)
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", user_role_enum, nullable=False),
|
||||
sa.Column("department", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", user_status_enum, nullable=False, server_default="PENDING"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("approved_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
bind = op.get_bind()
|
||||
user_role_enum.drop(bind, checkfirst=True)
|
||||
user_status_enum.drop(bind, checkfirst=True)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""add avatar_url to users"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20240501_000002"
|
||||
down_revision = "20240501_000001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("avatar_url", sa.String(length=500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "avatar_url")
|
||||
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, require_roles
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
|
||||
|
||||
@router.get("/", response_model=AdminUserListResponse)
|
||||
async def list_users_for_review(
|
||||
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
) -> AdminUserListResponse:
|
||||
users = await user_crud.list_users_by_status(db, status=status_filter)
|
||||
return AdminUserListResponse(items=list(users), total=len(users))
|
||||
|
||||
|
||||
async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await user_crud.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.role == UserRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/{user_id}/approve", response_model=UserResponse)
|
||||
async def approve_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
) -> UserResponse:
|
||||
if review.action != "approve":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
user = await _get_review_user(db, user_id)
|
||||
if user.status != UserStatus.PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
||||
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/{user_id}/reject", response_model=UserResponse)
|
||||
async def reject_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
) -> UserResponse:
|
||||
if review.action not in ("reject", "approve"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
user = await _get_review_user(db, user_id)
|
||||
if user.status != UserStatus.PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
|
||||
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
|
||||
return user
|
||||
@@ -1,40 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import user as user_crud
|
||||
from app.schemas.user import Token, UserRead
|
||||
from app.models.user import UserRole, UserStatus
|
||||
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if payload.role == UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号")
|
||||
existing = await user_crud.get_by_email(db, payload.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||
await user_crud.create_pending_user(db, payload)
|
||||
return {"message": "注册成功,等待管理员审核"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
db_user = await user_crud.get_by_username(db, payload.username)
|
||||
if not db_user or not verify_password(payload.password, db_user.hashed_password):
|
||||
db_user = await user_crud.get_by_email(db, payload.email)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
detail="账号不存在",
|
||||
)
|
||||
if not db_user.is_active:
|
||||
if not verify_password(payload.password, db_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive user",
|
||||
detail="密码错误",
|
||||
)
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号未审核或不可用",
|
||||
)
|
||||
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role,
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
@@ -43,3 +69,60 @@ async def login_for_access_token(
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def read_me(current_user=Depends(get_current_user)) -> UserRead:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
async def update_me(
|
||||
payload: UserSelfUpdate,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserRead:
|
||||
if payload.password:
|
||||
if not payload.current_password or not verify_password(payload.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
||||
update_data = {
|
||||
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
|
||||
"department": payload.department if payload.department is not None else current_user.department,
|
||||
"password": payload.password if payload.password else None,
|
||||
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
|
||||
}
|
||||
updated = await user_crud.update_user(db, current_user, UserUpdate(**update_data))
|
||||
return updated
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=UserRead)
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserRead:
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_dir = AVATAR_ROOT / str(current_user.id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(file.filename).suffix or ".png"
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
dest = user_dir / filename
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
# Optionally clean old files
|
||||
for old in user_dir.iterdir():
|
||||
if old.name != filename:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
rel_url = f"/api/v1/auth/avatar/{current_user.id}/{filename}"
|
||||
updated = await user_crud.update_user(
|
||||
db,
|
||||
current_user,
|
||||
UserUpdate(avatar_url=rel_url),
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@router.get("/avatar/{user_id}/{filename}")
|
||||
async def get_avatar(user_id: str, filename: str):
|
||||
file_path = AVATAR_ROOT / user_id / filename
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found")
|
||||
return FileResponse(file_path)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def create_comment(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="CREATE_COMMENT",
|
||||
detail=f"Comment created by {current_user.username}",
|
||||
detail=f"Comment created by {current_user.full_name}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||
|
||||
@@ -32,11 +32,11 @@ async def create_user(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> UserRead:
|
||||
existing = await user_crud.get_by_username(db, user_in.username)
|
||||
existing = await user_crud.get_by_email(db, user_in.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already exists",
|
||||
detail="Email already exists",
|
||||
)
|
||||
user = await user_crud.create_user(db, user_in)
|
||||
return user
|
||||
@@ -64,7 +64,7 @@ async def delete_user(
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
):
|
||||
admin_password = payload.get("admin_password")
|
||||
if not admin_password or not verify_password(admin_password, current_user.hashed_password):
|
||||
if not admin_password or not verify_password(admin_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
|
||||
db_user = await user_crud.get_by_id(db, user_id)
|
||||
if not db_user:
|
||||
|
||||
@@ -46,7 +46,8 @@ def require_roles(roles: Iterable[str]) -> Callable:
|
||||
roles_set = set(roles)
|
||||
|
||||
async def dependency(current_user=Depends(get_current_user)):
|
||||
if current_user.role not in roles_set:
|
||||
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
|
||||
if current_role not in roles_set:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions",
|
||||
@@ -61,7 +62,8 @@ async def get_study_member(
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if current_user.role == "ADMIN":
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return None
|
||||
return await member_crud.get_member(db, study_id, current_user.id)
|
||||
|
||||
@@ -72,7 +74,8 @@ def require_study_member():
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if current_user.role == "ADMIN":
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return current_user
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
@@ -94,7 +97,8 @@ def require_study_roles(roles: Iterable[str]):
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if current_user.role == "ADMIN":
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return current_user
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
|
||||
|
||||
+70
-23
@@ -1,17 +1,17 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.models.study_member import StudyMember
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
|
||||
|
||||
|
||||
async def get_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -20,12 +20,17 @@ async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
|
||||
async def create_user(
|
||||
db: AsyncSession, user_in: UserCreate, *, status: UserStatus | None = UserStatus.ACTIVE
|
||||
) -> User:
|
||||
status_value = status or UserStatus(user_in.status)
|
||||
user = User(
|
||||
username=user_in.username,
|
||||
hashed_password=hash_password(user_in.password),
|
||||
role=user_in.role,
|
||||
is_active=True,
|
||||
email=user_in.email,
|
||||
password_hash=hash_password(user_in.password),
|
||||
full_name=user_in.full_name,
|
||||
role=UserRole(user_in.role),
|
||||
department=user_in.department,
|
||||
status=status_value,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
@@ -33,21 +38,29 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def create_pending_user(db: AsyncSession, user_in: UserRegisterRequest) -> User:
|
||||
return await create_user(db, UserCreate(**user_in.model_dump()), status=UserStatus.PENDING)
|
||||
|
||||
|
||||
async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
|
||||
update_data = {}
|
||||
if user_in.role is not None:
|
||||
update_data["role"] = user_in.role
|
||||
update_data["role"] = UserRole(user_in.role)
|
||||
if user_in.status is not None:
|
||||
update_data["status"] = UserStatus(user_in.status)
|
||||
if user_in.is_active is not None:
|
||||
update_data["is_active"] = user_in.is_active
|
||||
update_data["status"] = UserStatus.ACTIVE if user_in.is_active else UserStatus.DISABLED
|
||||
if user_in.avatar_url is not None:
|
||||
update_data["avatar_url"] = user_in.avatar_url
|
||||
if user_in.password:
|
||||
update_data["hashed_password"] = hash_password(user_in.password)
|
||||
update_data["password_hash"] = hash_password(user_in.password)
|
||||
if user_in.full_name is not None:
|
||||
update_data["full_name"] = user_in.full_name
|
||||
if user_in.department is not None:
|
||||
update_data["department"] = user_in.department
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(update(User).where(User.id == user.id).values(**update_data))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
@@ -58,16 +71,28 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def list_users_by_status(
|
||||
db: AsyncSession, status: UserStatus | None = None, skip: int = 0, limit: int = 100
|
||||
) -> Sequence[User]:
|
||||
stmt = select(User).offset(skip).limit(limit)
|
||||
if status:
|
||||
stmt = stmt.where(User.status == status)
|
||||
result = await db.execute(stmt.order_by(User.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None:
|
||||
result = await db.execute(select(User).where(User.username == "admin"))
|
||||
result = await db.execute(select(User).where(User.role == UserRole.ADMIN))
|
||||
admin = result.scalar_one_or_none()
|
||||
if admin:
|
||||
return
|
||||
new_admin = User(
|
||||
username="admin",
|
||||
hashed_password=hash_password(default_password),
|
||||
role="ADMIN",
|
||||
is_active=True,
|
||||
email="admin@ctms.local",
|
||||
password_hash=hash_password(default_password),
|
||||
full_name="System Admin",
|
||||
role=UserRole.ADMIN,
|
||||
department="SYSTEM",
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
db.add(new_admin)
|
||||
await db.commit()
|
||||
@@ -85,3 +110,25 @@ async def delete_user(db: AsyncSession, user: User) -> None:
|
||||
await db.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def approve_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(status=UserStatus.ACTIVE, approved_by=admin_id, approved_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def reject_user(db: AsyncSession, user: User, admin_id: uuid.UUID) -> User:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(status=UserStatus.REJECTED, approved_by=admin_id, approved_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
@@ -1,19 +1,67 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "ADMIN"
|
||||
PM = "PM"
|
||||
CRA = "CRA"
|
||||
PV = "PV"
|
||||
IMP = "IMP"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
ACTIVE = "ACTIVE"
|
||||
REJECTED = "REJECTED"
|
||||
DISABLED = "DISABLED"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
|
||||
department: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[UserStatus] = mapped_column(
|
||||
Enum(UserStatus, name="user_status"),
|
||||
nullable=False,
|
||||
server_default=UserStatus.PENDING.value,
|
||||
default=UserStatus.PENDING,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
approved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool: # compatibility helper for existing checks
|
||||
return self.status == UserStatus.ACTIVE
|
||||
|
||||
@property
|
||||
def username(self) -> str: # backward compatibility for existing UI copy
|
||||
return self.email
|
||||
|
||||
+71
-21
@@ -1,54 +1,104 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
||||
RegisterRole = Literal["PM", "CRA", "PV", "IMP"]
|
||||
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
||||
|
||||
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
|
||||
|
||||
|
||||
class UserDisplay(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
id: uuid.UUID
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
role: UserRole
|
||||
is_active: bool = True
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
class _PasswordValidator(BaseModel):
|
||||
password: Optional[str] = Field(default=None, min_length=8)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password_strength(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
if not PASSWORD_REGEX.match(v):
|
||||
raise ValueError("密码需至少 8 位且包含字母和数字")
|
||||
return v
|
||||
|
||||
|
||||
class UserRegisterRequest(_PasswordValidator):
|
||||
password: str = Field(min_length=8)
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1)
|
||||
role: RegisterRole
|
||||
department: str = Field(min_length=1)
|
||||
|
||||
|
||||
class UserCreate(_PasswordValidator):
|
||||
password: str = Field(min_length=8)
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1)
|
||||
role: UserRole
|
||||
department: str = Field(min_length=1)
|
||||
status: UserStatus = "ACTIVE"
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: EmailStr
|
||||
username: str
|
||||
full_name: str
|
||||
role: UserRole
|
||||
department: str
|
||||
status: UserStatus
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
approved_at: Optional[datetime] = None
|
||||
approved_by: Optional[uuid.UUID] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
class UserUpdate(_PasswordValidator):
|
||||
email: Optional[EmailStr] = None
|
||||
full_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
department: Optional[str] = None
|
||||
status: Optional[UserStatus] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = Field(default=None, min_length=1)
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
id: uuid.UUID
|
||||
hashed_password: str
|
||||
created_at: datetime
|
||||
class UserResponse(UserRead):
|
||||
pass
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserAdminReviewRequest(BaseModel):
|
||||
action: Literal["approve", "reject"]
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserSelfUpdate(_PasswordValidator):
|
||||
full_name: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
current_password: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
|
||||
@@ -9,3 +9,7 @@ bcrypt==4.0.1
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
debugpy==1.8.0
|
||||
pytest==8.1.1
|
||||
pytest-asyncio==0.23.5
|
||||
httpx==0.25.2
|
||||
email-validator==2.1.1
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.main import create_app
|
||||
from app.core.deps import get_db_session
|
||||
from app.core.security import hash_password
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import User, UserRole, UserStatus
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client_and_db():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, future=True)
|
||||
TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(User.__table__.create)
|
||||
|
||||
async def override_get_db():
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_db_session] = override_get_db
|
||||
|
||||
async with TestSessionLocal() as session:
|
||||
admin = User(
|
||||
email="admin@test.com",
|
||||
password_hash=hash_password("admin123"),
|
||||
full_name="Admin",
|
||||
department="Admin",
|
||||
role=UserRole.ADMIN,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client, TestSessionLocal
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_creates_pending_user(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
payload = {
|
||||
"email": "newuser@test.com",
|
||||
"password": "Password123",
|
||||
"full_name": "New User",
|
||||
"role": "CRA",
|
||||
"department": "Clinical",
|
||||
}
|
||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||
assert resp.status_code == 201
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get_by_email(session, payload["email"])
|
||||
assert user is not None
|
||||
assert user.status == UserStatus.PENDING
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_blocked_before_approval(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
payload = {
|
||||
"email": "pending@test.com",
|
||||
"password": "Password123",
|
||||
"full_name": "Pending User",
|
||||
"role": "PV",
|
||||
"department": "Safety",
|
||||
}
|
||||
await client.post("/api/v1/auth/register", json=payload)
|
||||
resp = await client.post("/api/v1/auth/login", json={"email": payload["email"], "password": payload["password"]})
|
||||
assert resp.status_code == 401
|
||||
assert "账号未审核" in resp.json().get("detail", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_approve_user(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
payload = {
|
||||
"email": "approve@test.com",
|
||||
"password": "Password123",
|
||||
"full_name": "Approve Target",
|
||||
"role": "IMP",
|
||||
"department": "Supply",
|
||||
}
|
||||
await client.post("/api/v1/auth/register", json=payload)
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get_by_email(session, payload["email"])
|
||||
user_id = user.id
|
||||
|
||||
admin_login = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"})
|
||||
token = admin_login.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.post(f"/api/v1/admin/users/{user_id}/approve", json={"action": "approve"}, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
async with SessionLocal() as session:
|
||||
refreshed = await user_crud.get_by_email(session, payload["email"])
|
||||
assert refreshed.status == UserStatus.ACTIVE
|
||||
assert refreshed.approved_by is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_role_cannot_register(client_and_db):
|
||||
client, _ = client_and_db
|
||||
payload = {
|
||||
"email": "admin-register@test.com",
|
||||
"password": "Password123",
|
||||
"full_name": "Bad Admin",
|
||||
"role": "ADMIN",
|
||||
"department": "IT",
|
||||
}
|
||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||
assert resp.status_code in (400, 422)
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { apiGet, apiPost } from "./axios";
|
||||
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api";
|
||||
|
||||
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> =>
|
||||
apiGet("/api/v1/admin/users", { params: { status } });
|
||||
|
||||
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
|
||||
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" });
|
||||
|
||||
export const rejectUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
|
||||
apiPost(`/api/v1/admin/users/${userId}/reject`, { action: "reject" });
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import api, { apiGet, apiPost } from "./axios";
|
||||
import type { UserMeResponse, LoginRequest, LoginResponse } from "../types/api";
|
||||
import api, { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api";
|
||||
|
||||
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
|
||||
apiPost<LoginResponse>("/api/v1/auth/login", payload);
|
||||
|
||||
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
|
||||
|
||||
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
|
||||
apiPost("/api/v1/auth/register", payload);
|
||||
|
||||
export const updateProfile = (payload: {
|
||||
full_name?: string;
|
||||
department?: string;
|
||||
current_password?: string;
|
||||
password?: string;
|
||||
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -27,6 +27,12 @@ instance.interceptors.response.use(
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
const reqUrl = error.config?.url || "";
|
||||
const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register");
|
||||
if (isAuthEndpoint) {
|
||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// 401 统一处理:同步清理本地 token,并防止重复弹窗
|
||||
const handleUnauthorized = () => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
@@ -5,10 +5,19 @@ import { apiDelete } from "./axios";
|
||||
export const fetchUsers = (params?: Record<string, any>) =>
|
||||
apiGet<ApiListResponse<UserInfo>>("/api/v1/users", { params });
|
||||
|
||||
export const createUser = (payload: { username: string; password: string; role: string }) =>
|
||||
export const createUser = (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
role: string;
|
||||
department: string;
|
||||
}) =>
|
||||
apiPost<UserInfo>("/api/v1/users", payload);
|
||||
|
||||
export const updateUser = (userId: string, payload: Partial<{ role: string; is_active: boolean; password: string }>) =>
|
||||
export const updateUser = (
|
||||
userId: string,
|
||||
payload: Partial<{ role: string; status: string; password: string; full_name: string; department: string; is_active: boolean }>
|
||||
) =>
|
||||
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
|
||||
|
||||
export const deleteUser = (userId: string, payload: { admin_password: string }) =>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/study/milestones">
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span>里程碑管理</span>
|
||||
<span>伦理与启动</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/study/subjects">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
@@ -94,14 +94,28 @@
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<StudySelector />
|
||||
<div class="user-profile">
|
||||
<div class="user-avatar">{{ auth.user?.username?.charAt(0).toUpperCase() }}</div>
|
||||
<div class="user-details">
|
||||
<span class="username">{{ auth.user?.username }}</span>
|
||||
<span class="user-role">{{ auth.user?.role }}</span>
|
||||
</div>
|
||||
<el-button link type="danger" @click="onLogout" class="logout-btn">退出</el-button>
|
||||
</div>
|
||||
<el-dropdown trigger="click" @command="onCommand" class="user-profile">
|
||||
<span class="dropdown-trigger compact">
|
||||
<div class="user-avatar">
|
||||
<img v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" alt="avatar" />
|
||||
<span v-else>{{ (auth.user?.username || auth.user?.email || "用户").charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<span class="username">{{ auth.user?.username || auth.user?.email || "用户" }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="user-dropdown-menu">
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人中心</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
@@ -110,6 +124,7 @@
|
||||
<router-view />
|
||||
</div>
|
||||
</el-main>
|
||||
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
@@ -123,7 +138,7 @@ import StudySelector from "./StudySelector.vue";
|
||||
import {
|
||||
Monitor, User, Suitcase, List, House, Timer, UserFilled,
|
||||
Warning, InfoFilled, QuestionFilled, Checked, Management,
|
||||
Money, Collection, Memo
|
||||
Money, Collection, Memo, ArrowDown, SwitchButton
|
||||
} from "@element-plus/icons-vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -137,6 +152,15 @@ const onLogout = () => {
|
||||
study.clearCurrentStudy();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
const onCommand = (cmd: string) => {
|
||||
if (cmd === "logout") {
|
||||
onLogout();
|
||||
} else if (cmd === "profile") {
|
||||
// 预留个人设置路由占位,如后续有页面则跳转
|
||||
router.push("/profile");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -254,6 +278,7 @@ const onLogout = () => {
|
||||
gap: 12px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--ctms-border-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -267,6 +292,12 @@ const onLogout = () => {
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
@@ -290,6 +321,28 @@ const onLogout = () => {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dropdown-trigger.compact {
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
background: #f4f6fa;
|
||||
border: 1px solid #e2e6f0;
|
||||
color: #333;
|
||||
}
|
||||
.avatar-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.user-dropdown-menu :deep(.el-dropdown-menu__item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.study-tag {
|
||||
font-weight: 500;
|
||||
color: var(--ctms-primary);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-dialog :title="isEdit ? '编辑里程碑' : '新增里程碑'" v-model="visible" width="520px" @close="onClose">
|
||||
<el-dialog :title="isEdit ? '编辑节点' : '新增节点'" v-model="visible" width="520px" @close="onClose">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
@@ -98,7 +98,7 @@ const memberOptions = computed(() => {
|
||||
return memberList
|
||||
.map((m: any) => {
|
||||
const user = m.user || {};
|
||||
const label = user.display_name || user.username || m.username || "—";
|
||||
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
|
||||
return { value: m.user_id, label };
|
||||
})
|
||||
.filter((opt) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
const router = useRouter();
|
||||
|
||||
const actions = [
|
||||
{ label: "里程碑管理", path: "/study/milestones", icon: Timer, color: "#2f54eb" },
|
||||
{ label: "伦理与启动", path: "/study/milestones", icon: Timer, color: "#2f54eb" },
|
||||
{ label: "受试者", path: "/study/subjects", icon: UserFilled, color: "#722ed1" },
|
||||
{ label: "不良事件", path: "/study/aes", icon: Warning, color: "#faad14" },
|
||||
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled, color: "#1890ff" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import Layout from "../components/Layout.vue";
|
||||
import Login from "../views/Login.vue";
|
||||
import Register from "../views/Register.vue";
|
||||
import StudyList from "../views/StudyList.vue";
|
||||
import StudyHome from "../views/StudyHome.vue";
|
||||
import Milestones from "../views/Milestones.vue";
|
||||
@@ -28,10 +29,12 @@ import IssueDetail from "../views/IssueDetail.vue";
|
||||
import Verification from "../views/Verification.vue";
|
||||
import AuditLogs from "../views/admin/AuditLogs.vue";
|
||||
import AdminUsers from "../views/admin/Users.vue";
|
||||
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
|
||||
import AdminProjects from "../views/admin/Projects.vue";
|
||||
import ProjectMembers from "../views/admin/ProjectMembers.vue";
|
||||
import AdminSites from "../views/admin/Sites.vue";
|
||||
import ProjectDetail from "../views/admin/ProjectDetail.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -40,6 +43,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: Login,
|
||||
meta: { public: true, title: "登录" },
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "Register",
|
||||
component: Register,
|
||||
meta: { public: true, title: "注册" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: Layout,
|
||||
@@ -51,6 +60,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: MyWorkbench,
|
||||
meta: { title: "我的工作台" },
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
name: "ProfileSettings",
|
||||
component: ProfileSettings,
|
||||
meta: { title: "个人设置" },
|
||||
},
|
||||
{
|
||||
path: "study/home",
|
||||
name: "StudyHome",
|
||||
@@ -61,7 +76,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "study/milestones",
|
||||
name: "StudyMilestones",
|
||||
component: Milestones,
|
||||
meta: { title: "里程碑", requiresStudy: true },
|
||||
meta: { title: "伦理与启动", requiresStudy: true },
|
||||
},
|
||||
{
|
||||
path: "study/milestones/:milestoneId",
|
||||
@@ -196,6 +211,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: AdminUsers,
|
||||
meta: { title: "账号管理", requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "user-approval",
|
||||
name: "AdminUserApproval",
|
||||
component: AdminUserApproval,
|
||||
meta: { title: "注册审核", requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
name: "AdminProjects",
|
||||
@@ -257,9 +278,11 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
if (to.path === "/login" && token) {
|
||||
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
|
||||
return;
|
||||
if ((to.path === "/login" || to.path === "/register") && token) {
|
||||
if (!auth.forceLogin) {
|
||||
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") {
|
||||
next({ path: "/studies" });
|
||||
|
||||
@@ -9,14 +9,16 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref<string | null>(getToken());
|
||||
const user = ref<UserInfo | null>(null);
|
||||
const loading = ref(false);
|
||||
const forceLogin = ref(false);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const login = async (email: string, password: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await apiLogin({ username, password });
|
||||
const { data } = await apiLogin({ email, password });
|
||||
token.value = data.access_token;
|
||||
setToken(data.access_token);
|
||||
await fetchMeAction();
|
||||
forceLogin.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const logout = () => {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
clearToken();
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.clearCurrentStudy();
|
||||
@@ -40,8 +43,12 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
forceLogin,
|
||||
login,
|
||||
fetchMe: fetchMeAction,
|
||||
logout,
|
||||
requestReLogin: () => {
|
||||
forceLogin.value = true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ApiError {
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -19,17 +19,38 @@ export interface LoginResponse {
|
||||
}
|
||||
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN";
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
full_name: string;
|
||||
department: string;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
status: UserStatus;
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
approved_at?: string | null;
|
||||
approved_by?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export type UserMeResponse = UserInfo;
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
role: Exclude<UserRole, "ADMIN">;
|
||||
department: string;
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
items: UserInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Study {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TOKEN_KEY = "ctms_token";
|
||||
const CREDENTIAL_KEY = "ctms_credential";
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
@@ -9,3 +10,30 @@ export const setToken = (token: string): void => {
|
||||
export const clearToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
export const setCachedCredential = (email: string, password: string) => {
|
||||
try {
|
||||
const payload = btoa(JSON.stringify({ email, password }));
|
||||
localStorage.setItem(CREDENTIAL_KEY, payload);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
export const getCachedCredential = (): { email: string; password: string } | null => {
|
||||
const value = localStorage.getItem(CREDENTIAL_KEY);
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(atob(value));
|
||||
if (parsed?.email && parsed?.password) {
|
||||
return { email: parsed.email, password: parsed.password };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const clearCachedCredential = (): void => {
|
||||
localStorage.removeItem(CREDENTIAL_KEY);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-card class="login-card">
|
||||
<div class="login-header">
|
||||
<h2 class="form-title">用户登录</h2>
|
||||
<p class="form-desc">请输入您的账号与密码以访问系统</p>
|
||||
<p class="form-desc">请输入邮箱与密码登录,未激活账号将无法登录</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
@@ -19,11 +19,11 @@
|
||||
label-position="top"
|
||||
@keyup.enter.native="onSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
v-model="form.email"
|
||||
placeholder="请输入邮箱"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
@@ -37,6 +37,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-footer">
|
||||
<el-checkbox v-model="form.remember">记住账号密码</el-checkbox>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@@ -45,6 +46,9 @@
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
<div class="link-row">
|
||||
<RouterLink to="/register">没有账号?注册</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -57,40 +61,60 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElCheckbox, ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||
email: [
|
||||
{ required: true, message: "请输入邮箱", trigger: "blur" },
|
||||
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
|
||||
],
|
||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
const cached = getCachedCredential();
|
||||
if (cached) {
|
||||
form.email = cached.email;
|
||||
form.password = cached.password;
|
||||
form.remember = true;
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.username, form.password);
|
||||
await auth.fetchMe();
|
||||
router.push("/");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
const valid = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
if (form.remember) {
|
||||
setCachedCredential(form.email, form.password);
|
||||
} else {
|
||||
clearCachedCredential();
|
||||
}
|
||||
});
|
||||
router.push("/");
|
||||
} catch (error: any) {
|
||||
const detail = error?.response?.data?.detail || error?.response?.data?.message;
|
||||
ElMessage.error(detail || "邮箱或密码错误");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -169,6 +193,14 @@ const onSubmit = async () => {
|
||||
font-size: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.link-row {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.link-row a {
|
||||
color: var(--ctms-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
|
||||
@@ -102,7 +102,7 @@ const loadMilestones = async () => {
|
||||
const { data } = await fetchMilestones(study.currentStudy.id);
|
||||
milestones.value = data.items || data;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "里程碑加载失败");
|
||||
ElMessage.error(e?.response?.data?.message || "节点加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -150,7 +150,13 @@ const statusColor = (v: string) => getMilestoneStatusColor(v);
|
||||
|
||||
const memberMap = computed(() =>
|
||||
members.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
|
||||
const username =
|
||||
cur?.user?.full_name ||
|
||||
cur?.user?.display_name ||
|
||||
cur?.user?.username ||
|
||||
cur?.user?.email ||
|
||||
cur?.username ||
|
||||
cur?.email;
|
||||
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
|
||||
return acc;
|
||||
}, {})
|
||||
@@ -166,8 +172,10 @@ const siteName = (row: any) => {
|
||||
const ownerName = (ownerId: string) => {
|
||||
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
|
||||
return (
|
||||
fromPayload?.full_name ||
|
||||
fromPayload?.display_name ||
|
||||
fromPayload?.username ||
|
||||
fromPayload?.email ||
|
||||
displayUser(ownerId, { members: memberMap.value })
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<h3 class="title">个人设置</h3>
|
||||
<p class="subtitle">更新个人信息或修改登录密码</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
|
||||
<el-form-item label="头像">
|
||||
<div class="avatar-row">
|
||||
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
|
||||
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button>上传头像</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="full_name">
|
||||
<el-input v-model="form.full_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入部门" />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
<el-form-item label="当前密码" prop="current_password">
|
||||
<el-input v-model="form.current_password" type="password" show-password placeholder="修改密码时必填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="不修改请留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { updateProfile, fetchMe } from "../api/auth";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getToken } from "../utils/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
email: "",
|
||||
full_name: "",
|
||||
department: "",
|
||||
current_password: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||
Authorization: `Bearer ${getToken() || ""}`,
|
||||
}));
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
|
||||
current_password: [
|
||||
{
|
||||
validator: (_r, value, cb) => {
|
||||
if (form.password && !value) {
|
||||
cb(new Error("修改密码需输入当前密码"));
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
validator: (_r, value, cb) => {
|
||||
if (!value) return cb();
|
||||
if (value.length < 8 || !/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
|
||||
cb(new Error("至少 8 位且包含字母与数字"));
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
confirmPassword: [
|
||||
{
|
||||
validator: (_r, value, cb) => {
|
||||
if (form.password && value !== form.password) {
|
||||
cb(new Error("两次输入的密码不一致"));
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
const { data } = await fetchMe();
|
||||
form.email = data.email;
|
||||
form.full_name = data.full_name;
|
||||
form.department = data.department;
|
||||
avatarPreview.value = data.avatar_url || undefined;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await updateProfile({
|
||||
full_name: form.full_name,
|
||||
department: form.department,
|
||||
current_password: form.password ? form.current_password : undefined,
|
||||
password: form.password || undefined,
|
||||
});
|
||||
await auth.fetchMe();
|
||||
ElMessage.success("已更新");
|
||||
form.current_password = "";
|
||||
form.password = "";
|
||||
form.confirmPassword = "";
|
||||
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || "更新失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onAvatarUploaded = async () => {
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
ElMessage.success("头像已更新");
|
||||
};
|
||||
|
||||
const onAvatarError = (err: any) => {
|
||||
ElMessage.error(err?.response?.data?.message || "头像上传失败");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 16px;
|
||||
color: #666;
|
||||
}
|
||||
.form {
|
||||
max-width: 520px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-card">
|
||||
<div class="header">
|
||||
<h2>创建账号</h2>
|
||||
<p>提交后需管理员审核通过方可登录</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@keyup.enter.native="onSubmit"
|
||||
>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="至少 8 位且包含字母与数字"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="full_name">
|
||||
<el-input v-model="form.full_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入部门" />
|
||||
</el-form-item>
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交注册</el-button>
|
||||
<RouterLink to="/login">已有账号?返回登录</RouterLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { register as apiRegister } from "../api/auth";
|
||||
import type { RegisterRequest } from "../types/api";
|
||||
|
||||
const router = useRouter();
|
||||
const defaultRole: RegisterRequest["role"] = "CRA";
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
full_name: "",
|
||||
department: "",
|
||||
});
|
||||
const submitting = ref(false);
|
||||
|
||||
const passwordRule = {
|
||||
validator: (_: any, value: string, callback: (err?: Error) => void) => {
|
||||
if (!value || value.length < 8) {
|
||||
callback(new Error("密码至少 8 位"));
|
||||
return;
|
||||
}
|
||||
if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
|
||||
callback(new Error("需包含字母和数字"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "blur",
|
||||
};
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
email: [
|
||||
{ required: true, message: "请输入邮箱", trigger: "blur" },
|
||||
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||
passwordRule,
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "请确认密码", trigger: "blur" },
|
||||
{
|
||||
validator: (_: any, value: string, callback: (err?: Error) => void) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error("两次输入的密码不一致"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await apiRegister({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
full_name: form.full_name,
|
||||
role: defaultRole,
|
||||
department: form.department,
|
||||
});
|
||||
ElMessage.success("注册成功,等待管理员审核");
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
Object.assign(form, {
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
full_name: "",
|
||||
department: "",
|
||||
});
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f7f9fb 0%, #eef2f7 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.register-card {
|
||||
width: 420px;
|
||||
background: #fff;
|
||||
padding: 24px 28px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #f0f2f5;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.header p {
|
||||
margin: 6px 0 16px;
|
||||
color: #6c7a89;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.actions a {
|
||||
color: var(--ctms-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3>注册审核</h3>
|
||||
<p>仅显示待审核账号,审核通过后即可登录</p>
|
||||
</div>
|
||||
<el-select v-model="status" size="small" style="width: 180px" @change="loadUsers">
|
||||
<el-option label="待审核" value="PENDING" />
|
||||
<el-option label="已通过" value="ACTIVE" />
|
||||
<el-option label="已拒绝" value="REJECTED" />
|
||||
<el-option label="已禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||
<el-table-column prop="full_name" label="姓名" min-width="140" />
|
||||
<el-table-column prop="role" label="角色" width="120" />
|
||||
<el-table-column prop="department" label="部门" min-width="160" />
|
||||
<el-table-column prop="created_at" label="注册时间" width="200">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="onApprove(scope.row.id)"
|
||||
>
|
||||
审核通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
@click="onReject(scope.row.id)"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { approveUser, listPendingUsers, rejectUser } from "../../api/admin";
|
||||
import type { UserInfo, UserStatus } from "../../types/api";
|
||||
import { displayDateTime } from "../../utils/display";
|
||||
|
||||
const users = ref<UserInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const status = ref<UserStatus>("PENDING");
|
||||
|
||||
const statusType = (s: UserStatus) => {
|
||||
switch (s) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listPendingUsers(status.value);
|
||||
users.value = data.items || [];
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || "加载待审核用户失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onApprove = async (id: string) => {
|
||||
try {
|
||||
await approveUser(id);
|
||||
ElMessage.success("已通过审核");
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || "审核失败");
|
||||
}
|
||||
};
|
||||
|
||||
const onReject = async (id: string) => {
|
||||
try {
|
||||
await rejectUser(id);
|
||||
ElMessage.success("已拒绝");
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || "拒绝失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.header p {
|
||||
margin: 4px 0 0;
|
||||
color: #6c7a89;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<el-dialog :title="user ? '编辑用户' : '新建用户'" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" :disabled="!!user" placeholder="请输入用户名" />
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" :disabled="!!user" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="full_name">
|
||||
<el-input v-model="form.full_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-input v-model="form.department" placeholder="请输入部门" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password">
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
|
||||
@@ -42,13 +48,22 @@ const visibleProxy = computed({
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
username: "",
|
||||
email: "",
|
||||
full_name: "",
|
||||
department: "",
|
||||
password: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const defaultRole = "PV";
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
|
||||
email: [
|
||||
{ required: true, message: "请输入邮箱", trigger: "blur" },
|
||||
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
|
||||
],
|
||||
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
|
||||
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
|
||||
password: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
@@ -64,7 +79,9 @@ const rules = reactive<FormRules>({
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
form.username = "";
|
||||
form.email = "";
|
||||
form.full_name = "";
|
||||
form.department = "";
|
||||
form.password = "";
|
||||
form.is_active = true;
|
||||
};
|
||||
@@ -75,7 +92,9 @@ watch(
|
||||
if (val) {
|
||||
resetForm();
|
||||
if (props.user) {
|
||||
form.username = props.user.username;
|
||||
form.email = props.user.email;
|
||||
form.full_name = props.user.full_name;
|
||||
form.department = props.user.department;
|
||||
form.is_active = props.user.is_active;
|
||||
}
|
||||
}
|
||||
@@ -90,13 +109,17 @@ const onSubmit = async () => {
|
||||
if (props.user) {
|
||||
await updateUser(props.user.id, {
|
||||
is_active: form.is_active,
|
||||
full_name: form.full_name,
|
||||
department: form.department,
|
||||
password: form.password || undefined,
|
||||
});
|
||||
ElMessage.success("用户已更新");
|
||||
} else {
|
||||
await createUser({
|
||||
username: form.username,
|
||||
role: "PV",
|
||||
email: form.email,
|
||||
full_name: form.full_name,
|
||||
department: form.department,
|
||||
role: defaultRole,
|
||||
password: form.password,
|
||||
});
|
||||
ElMessage.success("用户已创建");
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||
<el-table-column prop="full_name" label="姓名" min-width="140" />
|
||||
<el-table-column prop="department" label="部门" min-width="140" />
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag>
|
||||
<el-tag type="danger" v-else>已禁用</el-tag>
|
||||
<el-tag :type="statusType(scope.row.status)">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="200">
|
||||
@@ -24,11 +27,11 @@
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleStatus(scope.row)"
|
||||
>
|
||||
{{ scope.row.is_active ? "禁用" : "启用" }}
|
||||
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button>
|
||||
<el-button
|
||||
@@ -105,7 +108,7 @@ const openEdit = (row: UserInfo) => {
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: UserInfo) => {
|
||||
const disable = row.is_active;
|
||||
const disable = row.status === "ACTIVE";
|
||||
const ok = await ElMessageBox.confirm(
|
||||
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
|
||||
disable ? "禁用账号" : "启用账号",
|
||||
@@ -117,7 +120,7 @@ const toggleStatus = async (row: UserInfo) => {
|
||||
).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await updateUser(row.id, { is_active: !row.is_active });
|
||||
await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
|
||||
ElMessage.success(disable ? "已禁用" : "已启用");
|
||||
loadUsers();
|
||||
} catch (e: any) {
|
||||
@@ -161,6 +164,34 @@ const onDelete = async (row: UserInfo) => {
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "启用";
|
||||
case "PENDING":
|
||||
return "待审核";
|
||||
case "REJECTED":
|
||||
return "已拒绝";
|
||||
case "DISABLED":
|
||||
return "已停用";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -31,6 +31,7 @@ http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 20m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
|
||||
Reference in New Issue
Block a user