新增用户注册功能

This commit is contained in:
Cheng Zhou
2025-12-22 21:19:48 +08:00
parent 03fddba406
commit 6ddf9901a0
33 changed files with 1389 additions and 138 deletions
+7 -7
View File
@@ -1,11 +1,10 @@
# CTMS 项目快速上手 # CTMS 项目快速上手
## 账号(示例) ## 账号与注册
- ADMIN`admin / 123456` - 初始化管理员`admin@example.com / admin123`(已修正邮箱域名,后端启动时自动创建)
- PM`pm1 / 123456` - 自助注册:前端 `/register` 提交邮箱、密码、姓名、角色(CRA/PV/IMP/PM)、部门,状态为 PENDING。
- CRA`cra1 / 123456` - 管理员审核:`/admin/user-approval` 列表查看待审核,支持通过/拒绝;仅 ACTIVE 用户可登录。
- 更多账号:管理员也可在 `/admin/users` 直接创建 ACTIVE 账号,再在项目成员中赋予角色。
> 如需更多账号,可在前端 `/admin/users` 由 ADMIN 创建,并在项目成员中赋予角色。
## 角色权限概要(前端操作级提示,后端仍最终裁决) ## 角色权限概要(前端操作级提示,后端仍最终裁决)
- ADMIN:全权限 - ADMIN:全权限
@@ -20,7 +19,7 @@
- 后端 API:同域 `/api/v1/*`(已在前端代理) - 后端 API:同域 `/api/v1/*`(已在前端代理)
## 常用流程 ## 常用流程
1. `admin / Password123!` 登录前端 1.管理员账号登录前端(默认 `admin@example.com / admin123`
2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。 2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。 3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
4. 退出登录或切换项目可通过顶部右侧下拉。 4. 退出登录或切换项目可通过顶部右侧下拉。
@@ -33,5 +32,6 @@
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。 > ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
## 注意 ## 注意
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。 - 令牌与当前项目上下文保存在浏览器 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")
+62
View File
@@ -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
+92 -9
View File
@@ -1,40 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status 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 sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.security import create_access_token, verify_password from app.core.security import create_access_token, verify_password
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud 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): class LoginRequest(BaseModel):
username: str = Field(min_length=1) email: EmailStr
password: str = Field(min_length=1) password: str = Field(min_length=1)
router = APIRouter() 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) @router.post("/login", response_model=Token)
async def login_for_access_token( async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session) payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token: ) -> Token:
db_user = await user_crud.get_by_username(db, payload.username) db_user = await user_crud.get_by_email(db, payload.email)
if not db_user or not verify_password(payload.password, db_user.hashed_password): if not db_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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( access_token = create_access_token(
user_id=str(db_user.id), 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, expires_minutes=None,
) )
return Token(access_token=access_token, token_type="bearer") 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) @router.get("/me", response_model=UserRead)
async def read_me(current_user=Depends(get_current_user)) -> UserRead: async def read_me(current_user=Depends(get_current_user)) -> UserRead:
return current_user 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)
+1 -1
View File
@@ -48,7 +48,7 @@ async def create_comment(
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, entity_id=entity_id,
action="CREATE_COMMENT", 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_id=current_user.id,
operator_role=current_user.role, operator_role=current_user.role,
) )
+2 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
+3 -3
View File
@@ -32,11 +32,11 @@ async def create_user(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])), current_user=Depends(require_roles(["ADMIN"])),
) -> UserRead: ) -> 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: if existing:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Username already exists", detail="Email already exists",
) )
user = await user_crud.create_user(db, user_in) user = await user_crud.create_user(db, user_in)
return user return user
@@ -64,7 +64,7 @@ async def delete_user(
current_user=Depends(require_roles(["ADMIN"])), current_user=Depends(require_roles(["ADMIN"])),
): ):
admin_password = payload.get("admin_password") 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="管理员密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员密码错误")
db_user = await user_crud.get_by_id(db, user_id) db_user = await user_crud.get_by_id(db, user_id)
if not db_user: if not db_user:
+8 -4
View File
@@ -46,7 +46,8 @@ def require_roles(roles: Iterable[str]) -> Callable:
roles_set = set(roles) roles_set = set(roles)
async def dependency(current_user=Depends(get_current_user)): 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions", detail="Insufficient permissions",
@@ -61,7 +62,8 @@ async def get_study_member(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), 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 None
return await member_crud.get_member(db, study_id, current_user.id) 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), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), 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 return current_user
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: 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), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), 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 return current_user
membership = await member_crud.get_member(db, study_id, current_user.id) 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: if not membership or not membership.is_active or membership.role_in_study not in roles_set:
+70 -23
View File
@@ -1,17 +1,17 @@
import uuid import uuid
from typing import Sequence from typing import Sequence
from sqlalchemy import select, update, delete from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password 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.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: async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.username == username)) result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none() 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() 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( user = User(
username=user_in.username, email=user_in.email,
hashed_password=hash_password(user_in.password), password_hash=hash_password(user_in.password),
role=user_in.role, full_name=user_in.full_name,
is_active=True, role=UserRole(user_in.role),
department=user_in.department,
status=status_value,
) )
db.add(user) db.add(user)
await db.commit() await db.commit()
@@ -33,21 +38,29 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
return 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: async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
update_data = {} update_data = {}
if user_in.role is not None: 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: 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: 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: if update_data:
await db.execute( await db.execute(update(User).where(User.id == user.id).values(**update_data))
update(User)
.where(User.id == user.id)
.values(**update_data)
)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
return user return user
@@ -58,16 +71,28 @@ async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Seque
return result.scalars().all() 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: 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() admin = result.scalar_one_or_none()
if admin: if admin:
return return
new_admin = User( new_admin = User(
username="admin", email="admin@ctms.local",
hashed_password=hash_password(default_password), password_hash=hash_password(default_password),
role="ADMIN", full_name="System Admin",
is_active=True, role=UserRole.ADMIN,
department="SYSTEM",
status=UserStatus.ACTIVE,
) )
db.add(new_admin) db.add(new_admin)
await db.commit() 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.execute(delete(StudyMember).where(StudyMember.user_id == user.id))
await db.delete(user) await db.delete(user)
await db.commit() 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
+54 -6
View File
@@ -1,19 +1,67 @@
import enum
import uuid import uuid
from datetime import datetime 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False) full_name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) 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
View File
@@ -1,54 +1,104 @@
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal, Optional 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"] 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): class UserDisplay(BaseModel):
id: uuid.UUID id: uuid.UUID
username: str email: EmailStr
display_name: Optional[str] = None full_name: str
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
username: str = Field(min_length=1)
role: UserRole role: UserRole
is_active: bool = True avatar_url: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserCreate(BaseModel): class _PasswordValidator(BaseModel):
username: str = Field(min_length=1) password: Optional[str] = Field(default=None, min_length=8)
password: str = Field(min_length=1)
@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 role: UserRole
department: str = Field(min_length=1)
status: UserStatus = "ACTIVE"
class UserRead(BaseModel): class UserRead(BaseModel):
id: uuid.UUID id: uuid.UUID
email: EmailStr
username: str username: str
full_name: str
role: UserRole role: UserRole
department: str
status: UserStatus
is_active: bool is_active: bool
created_at: datetime 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) 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 role: Optional[UserRole] = None
department: Optional[str] = None
status: Optional[UserStatus] = None
password: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
password: Optional[str] = Field(default=None, min_length=1) avatar_url: Optional[str] = None
class UserInDB(UserBase): class UserResponse(UserRead):
id: uuid.UUID pass
hashed_password: str
created_at: datetime
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): class Token(BaseModel):
+4
View File
@@ -9,3 +9,7 @@ bcrypt==4.0.1
python-multipart==0.0.6 python-multipart==0.0.6
aiofiles==23.2.1 aiofiles==23.2.1
debugpy==1.8.0 debugpy==1.8.0
pytest==8.1.1
pytest-asyncio==0.23.5
httpx==0.25.2
email-validator==2.1.1
+119
View File
@@ -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)
+12
View File
@@ -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" });
+12 -2
View File
@@ -1,10 +1,20 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import api, { apiGet, apiPost } from "./axios"; import api, { apiGet, apiPatch, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse } from "../types/api"; import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> => export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload); apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me"); 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; export default api;
+6
View File
@@ -27,6 +27,12 @@ instance.interceptors.response.use(
async (error: AxiosError<ApiError>) => { async (error: AxiosError<ApiError>) => {
const status = error.response?.status; const status = error.response?.status;
const data = error.response?.data; 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,并防止重复弹窗 // 401 统一处理:同步清理本地 token,并防止重复弹窗
const handleUnauthorized = () => { const handleUnauthorized = () => {
const auth = useAuthStore(); const auth = useAuthStore();
+11 -2
View File
@@ -5,10 +5,19 @@ import { apiDelete } from "./axios";
export const fetchUsers = (params?: Record<string, any>) => export const fetchUsers = (params?: Record<string, any>) =>
apiGet<ApiListResponse<UserInfo>>("/api/v1/users", { params }); 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); 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); apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string, payload: { admin_password: string }) => export const deleteUser = (userId: string, payload: { admin_password: string }) =>
+63 -10
View File
@@ -39,7 +39,7 @@
</el-menu-item> </el-menu-item>
<el-menu-item index="/study/milestones"> <el-menu-item index="/study/milestones">
<el-icon><Timer /></el-icon> <el-icon><Timer /></el-icon>
<span>里程碑管理</span> <span>伦理与启动</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/study/subjects"> <el-menu-item index="/study/subjects">
<el-icon><UserFilled /></el-icon> <el-icon><UserFilled /></el-icon>
@@ -94,14 +94,28 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<StudySelector /> <StudySelector />
<div class="user-profile"> <el-dropdown trigger="click" @command="onCommand" class="user-profile">
<div class="user-avatar">{{ auth.user?.username?.charAt(0).toUpperCase() }}</div> <span class="dropdown-trigger compact">
<div class="user-details"> <div class="user-avatar">
<span class="username">{{ auth.user?.username }}</span> <img v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" alt="avatar" />
<span class="user-role">{{ auth.user?.role }}</span> <span v-else>{{ (auth.user?.username || auth.user?.email || "用户").charAt(0).toUpperCase() }}</span>
</div> </div>
<el-button link type="danger" @click="onLogout" class="logout-btn">退出</el-button> <span class="username">{{ auth.user?.username || auth.user?.email || "用户" }}</span>
</div> <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> </div>
</el-header> </el-header>
@@ -110,6 +124,7 @@
<router-view /> <router-view />
</div> </div>
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
</template> </template>
@@ -123,7 +138,7 @@ import StudySelector from "./StudySelector.vue";
import { import {
Monitor, User, Suitcase, List, House, Timer, UserFilled, Monitor, User, Suitcase, List, House, Timer, UserFilled,
Warning, InfoFilled, QuestionFilled, Checked, Management, Warning, InfoFilled, QuestionFilled, Checked, Management,
Money, Collection, Memo Money, Collection, Memo, ArrowDown, SwitchButton
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -137,6 +152,15 @@ const onLogout = () => {
study.clearCurrentStudy(); study.clearCurrentStudy();
router.replace("/login"); router.replace("/login");
}; };
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "profile") {
// 预留个人设置路由占位,如后续有页面则跳转
router.push("/profile");
}
};
</script> </script>
<style scoped> <style scoped>
@@ -254,6 +278,7 @@ const onLogout = () => {
gap: 12px; gap: 12px;
padding-left: 16px; padding-left: 16px;
border-left: 1px solid var(--ctms-border-color); border-left: 1px solid var(--ctms-border-color);
cursor: pointer;
} }
.user-avatar { .user-avatar {
@@ -267,6 +292,12 @@ const onLogout = () => {
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.user-details { .user-details {
@@ -290,6 +321,28 @@ const onLogout = () => {
margin-left: 8px; 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 { .study-tag {
font-weight: 500; font-weight: 500;
color: var(--ctms-primary); color: var(--ctms-primary);
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <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 ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name"> <el-form-item label="名称" prop="name">
<el-input v-model="form.name" /> <el-input v-model="form.name" />
@@ -98,7 +98,7 @@ const memberOptions = computed(() => {
return memberList return memberList
.map((m: any) => { .map((m: any) => {
const user = m.user || {}; 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 }; return { value: m.user_id, label };
}) })
.filter((opt) => { .filter((opt) => {
+1 -1
View File
@@ -31,7 +31,7 @@ import {
const router = useRouter(); const router = useRouter();
const actions = [ 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/subjects", icon: UserFilled, color: "#722ed1" },
{ label: "不良事件", path: "/study/aes", icon: Warning, color: "#faad14" }, { label: "不良事件", path: "/study/aes", icon: Warning, color: "#faad14" },
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled, color: "#1890ff" }, { label: "数据问题", path: "/study/data-queries", icon: QuestionFilled, color: "#1890ff" },
+27 -4
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import Layout from "../components/Layout.vue"; import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import StudyList from "../views/StudyList.vue"; import StudyList from "../views/StudyList.vue";
import StudyHome from "../views/StudyHome.vue"; import StudyHome from "../views/StudyHome.vue";
import Milestones from "../views/Milestones.vue"; import Milestones from "../views/Milestones.vue";
@@ -28,10 +29,12 @@ import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue"; import Verification from "../views/Verification.vue";
import AuditLogs from "../views/admin/AuditLogs.vue"; import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue"; import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue"; import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue"; import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue"; import AdminSites from "../views/admin/Sites.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue"; import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -40,6 +43,12 @@ const routes: RouteRecordRaw[] = [
component: Login, component: Login,
meta: { public: true, title: "登录" }, meta: { public: true, title: "登录" },
}, },
{
path: "/register",
name: "Register",
component: Register,
meta: { public: true, title: "注册" },
},
{ {
path: "/", path: "/",
component: Layout, component: Layout,
@@ -51,6 +60,12 @@ const routes: RouteRecordRaw[] = [
component: MyWorkbench, component: MyWorkbench,
meta: { title: "我的工作台" }, meta: { title: "我的工作台" },
}, },
{
path: "profile",
name: "ProfileSettings",
component: ProfileSettings,
meta: { title: "个人设置" },
},
{ {
path: "study/home", path: "study/home",
name: "StudyHome", name: "StudyHome",
@@ -61,7 +76,7 @@ const routes: RouteRecordRaw[] = [
path: "study/milestones", path: "study/milestones",
name: "StudyMilestones", name: "StudyMilestones",
component: Milestones, component: Milestones,
meta: { title: "里程碑", requiresStudy: true }, meta: { title: "伦理与启动", requiresStudy: true },
}, },
{ {
path: "study/milestones/:milestoneId", path: "study/milestones/:milestoneId",
@@ -196,6 +211,12 @@ const routes: RouteRecordRaw[] = [
component: AdminUsers, component: AdminUsers,
meta: { title: "账号管理", requiresAdmin: true }, meta: { title: "账号管理", requiresAdmin: true },
}, },
{
path: "user-approval",
name: "AdminUserApproval",
component: AdminUserApproval,
meta: { title: "注册审核", requiresAdmin: true },
},
{ {
path: "projects", path: "projects",
name: "AdminProjects", name: "AdminProjects",
@@ -257,9 +278,11 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/login" }); next({ path: "/login" });
return; return;
} }
if (to.path === "/login" && token) { if ((to.path === "/login" || to.path === "/register") && token) {
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" }); if (!auth.forceLogin) {
return; next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
return;
}
} }
if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") { if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") {
next({ path: "/studies" }); next({ path: "/studies" });
+9 -2
View File
@@ -9,14 +9,16 @@ export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(getToken()); const token = ref<string | null>(getToken());
const user = ref<UserInfo | null>(null); const user = ref<UserInfo | null>(null);
const loading = ref(false); 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; loading.value = true;
try { try {
const { data } = await apiLogin({ username, password }); const { data } = await apiLogin({ email, password });
token.value = data.access_token; token.value = data.access_token;
setToken(data.access_token); setToken(data.access_token);
await fetchMeAction(); await fetchMeAction();
forceLogin.value = false;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -31,6 +33,7 @@ export const useAuthStore = defineStore("auth", () => {
const logout = () => { const logout = () => {
token.value = null; token.value = null;
user.value = null; user.value = null;
forceLogin.value = false;
clearToken(); clearToken();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
studyStore.clearCurrentStudy(); studyStore.clearCurrentStudy();
@@ -40,8 +43,12 @@ export const useAuthStore = defineStore("auth", () => {
token, token,
user, user,
loading, loading,
forceLogin,
login, login,
fetchMe: fetchMeAction, fetchMe: fetchMeAction,
logout, logout,
requestReLogin: () => {
forceLogin.value = true;
},
}; };
}); });
+24 -3
View File
@@ -9,7 +9,7 @@ export interface ApiError {
} }
export interface LoginRequest { export interface LoginRequest {
username: string; email: string;
password: string; password: string;
} }
@@ -19,17 +19,38 @@ export interface LoginResponse {
} }
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN"; export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo { export interface UserInfo {
id: string; id: string;
username: string; email: string;
username?: string;
full_name: string;
department: string;
role: UserRole; role: UserRole;
is_active: boolean; status: UserStatus;
is_active?: boolean;
created_at?: string; created_at?: string;
approved_at?: string | null;
approved_by?: string | null;
avatar_url?: string | null;
} }
export type UserMeResponse = UserInfo; 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 { export interface Study {
id: string; id: string;
code: string; code: string;
+28
View File
@@ -1,4 +1,5 @@
const TOKEN_KEY = "ctms_token"; const TOKEN_KEY = "ctms_token";
const CREDENTIAL_KEY = "ctms_credential";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
@@ -9,3 +10,30 @@ export const setToken = (token: string): void => {
export const clearToken = (): void => { export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY); 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);
};
+51 -19
View File
@@ -9,7 +9,7 @@
<el-card class="login-card"> <el-card class="login-card">
<div class="login-header"> <div class="login-header">
<h2 class="form-title">用户登录</h2> <h2 class="form-title">用户登录</h2>
<p class="form-desc">请输入您的账号与密码以访问系统</p> <p class="form-desc">请输入邮箱与密码登录未激活账号将无法登录</p>
</div> </div>
<el-form <el-form
@@ -19,11 +19,11 @@
label-position="top" label-position="top"
@keyup.enter.native="onSubmit" @keyup.enter.native="onSubmit"
> >
<el-form-item label="用户名" prop="username"> <el-form-item label="邮箱" prop="email">
<el-input <el-input
v-model="form.username" v-model="form.email"
placeholder="请输入用户名" placeholder="请输入邮箱"
autocomplete="username" autocomplete="email"
/> />
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
@@ -37,6 +37,7 @@
</el-form-item> </el-form-item>
<div class="form-footer"> <div class="form-footer">
<el-checkbox v-model="form.remember">记住账号密码</el-checkbox>
<el-button <el-button
type="primary" type="primary"
:loading="loading" :loading="loading"
@@ -45,6 +46,9 @@
> >
</el-button> </el-button>
<div class="link-row">
<RouterLink to="/register">没有账号注册</RouterLink>
</div>
</div> </div>
</el-form> </el-form>
</el-card> </el-card>
@@ -57,40 +61,60 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { reactive, ref, onMounted } from "vue";
import { useRouter } from "vue-router"; 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 { useAuthStore } from "../store/auth";
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const form = reactive({ const form = reactive({
username: "", email: "",
password: "", password: "",
remember: false,
}); });
const rules: FormRules<typeof form> = { 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" }], password: [{ required: true, message: "请输入密码", trigger: "blur" }],
}; };
const loading = ref(false); const loading = ref(false);
onMounted(() => {
const cached = getCachedCredential();
if (cached) {
form.email = cached.email;
form.password = cached.password;
form.remember = true;
}
});
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
await formRef.value.validate(async (valid) => { const valid = await formRef.value.validate();
if (!valid) return; if (!valid) return;
loading.value = true; loading.value = true;
try { try {
await auth.login(form.username, form.password); await auth.login(form.email, form.password);
await auth.fetchMe(); if (form.remember) {
router.push("/"); setCachedCredential(form.email, form.password);
} finally { } else {
loading.value = false; clearCachedCredential();
} }
}); router.push("/");
} catch (error: any) {
const detail = error?.response?.data?.detail || error?.response?.data?.message;
ElMessage.error(detail || "邮箱或密码错误");
} finally {
loading.value = false;
}
}; };
</script> </script>
@@ -169,6 +193,14 @@ const onSubmit = async () => {
font-size: 16px; font-size: 16px;
margin-top: 16px; margin-top: 16px;
} }
.link-row {
margin-top: 12px;
text-align: center;
}
.link-row a {
color: var(--ctms-primary);
font-size: 14px;
}
.login-footer { .login-footer {
text-align: center; text-align: center;
+10 -2
View File
@@ -102,7 +102,7 @@ const loadMilestones = async () => {
const { data } = await fetchMilestones(study.currentStudy.id); const { data } = await fetchMilestones(study.currentStudy.id);
milestones.value = data.items || data; milestones.value = data.items || data;
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "里程碑加载失败"); ElMessage.error(e?.response?.data?.message || "节点加载失败");
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -150,7 +150,13 @@ const statusColor = (v: string) => getMilestoneStatusColor(v);
const memberMap = computed(() => const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => { 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; if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc; return acc;
}, {}) }, {})
@@ -166,8 +172,10 @@ const siteName = (row: any) => {
const ownerName = (ownerId: string) => { const ownerName = (ownerId: string) => {
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner; const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
return ( return (
fromPayload?.full_name ||
fromPayload?.display_name || fromPayload?.display_name ||
fromPayload?.username || fromPayload?.username ||
fromPayload?.email ||
displayUser(ownerId, { members: memberMap.value }) displayUser(ownerId, { members: memberMap.value })
); );
}; };
+185
View File
@@ -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>
+180
View File
@@ -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>
+31 -8
View File
@@ -1,8 +1,14 @@
<template> <template>
<el-dialog :title="user ? '编辑用户' : '新建用户'" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <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 ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="用户名" prop="username"> <el-form-item label="邮箱" prop="email">
<el-input v-model="form.username" :disabled="!!user" placeholder="请输入用户名" /> <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>
<el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password"> <el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" /> <el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
@@ -42,13 +48,22 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive({
username: "", email: "",
full_name: "",
department: "",
password: "", password: "",
is_active: true, is_active: true,
}); });
const defaultRole = "PV";
const rules = reactive<FormRules>({ 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: [ password: [
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
@@ -64,7 +79,9 @@ const rules = reactive<FormRules>({
}); });
const resetForm = () => { const resetForm = () => {
form.username = ""; form.email = "";
form.full_name = "";
form.department = "";
form.password = ""; form.password = "";
form.is_active = true; form.is_active = true;
}; };
@@ -75,7 +92,9 @@ watch(
if (val) { if (val) {
resetForm(); resetForm();
if (props.user) { 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; form.is_active = props.user.is_active;
} }
} }
@@ -90,13 +109,17 @@ const onSubmit = async () => {
if (props.user) { if (props.user) {
await updateUser(props.user.id, { await updateUser(props.user.id, {
is_active: form.is_active, is_active: form.is_active,
full_name: form.full_name,
department: form.department,
password: form.password || undefined, password: form.password || undefined,
}); });
ElMessage.success("用户已更新"); ElMessage.success("用户已更新");
} else { } else {
await createUser({ await createUser({
username: form.username, email: form.email,
role: "PV", full_name: form.full_name,
department: form.department,
role: defaultRole,
password: form.password, password: form.password,
}); });
ElMessage.success("用户已创建"); ElMessage.success("用户已创建");
+39 -8
View File
@@ -9,11 +9,14 @@
<el-button type="primary" @click="openCreate">新建用户</el-button> <el-button type="primary" @click="openCreate">新建用户</el-button>
</div> </div>
<el-table :data="users" stripe v-loading="loading" style="width: 100%"> <el-table :data="users" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" min-width="140" /> <el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column label="状态" width="120"> <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"> <template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag> <el-tag :type="statusType(scope.row.status)">
<el-tag type="danger" v-else>已禁用</el-tag> {{ statusLabel(scope.row.status) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200"> <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="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button <el-button
link link
:type="scope.row.is_active ? 'danger' : 'primary'" :type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
size="small" size="small"
@click="toggleStatus(scope.row)" @click="toggleStatus(scope.row)"
> >
{{ scope.row.is_active ? "禁用" : "启用" }} {{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
</el-button> </el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button> <el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button>
<el-button <el-button
@@ -105,7 +108,7 @@ const openEdit = (row: UserInfo) => {
}; };
const toggleStatus = async (row: UserInfo) => { const toggleStatus = async (row: UserInfo) => {
const disable = row.is_active; const disable = row.status === "ACTIVE";
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?", disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
disable ? "禁用账号" : "启用账号", disable ? "禁用账号" : "启用账号",
@@ -117,7 +120,7 @@ const toggleStatus = async (row: UserInfo) => {
).catch(() => null); ).catch(() => null);
if (!ok) return; if (!ok) return;
try { try {
await updateUser(row.id, { is_active: !row.is_active }); await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
ElMessage.success(disable ? "已禁用" : "已启用"); ElMessage.success(disable ? "已禁用" : "已启用");
loadUsers(); loadUsers();
} catch (e: any) { } catch (e: any) {
@@ -161,6 +164,34 @@ const onDelete = async (row: UserInfo) => {
onMounted(() => { onMounted(() => {
loadUsers(); 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> </script>
<style scoped> <style scoped>
+1
View File
@@ -31,6 +31,7 @@ http {
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 20m;
location /api/ { location /api/ {
proxy_pass http://backend; proxy_pass http://backend;