diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 4a447132..a25cb3cc 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -42,6 +42,12 @@ class ExtendResponse(BaseModel): router = APIRouter() AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT.mkdir(parents=True, exist_ok=True) +AVATAR_ALLOWED_CONTENT_TYPES = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +} def issue_user_token(db_user) -> Token: @@ -215,10 +221,15 @@ async def upload_avatar( current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ) -> UserRead: + ext = AVATAR_ALLOWED_CONTENT_TYPES.get(file.content_type or "") + if not ext: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="头像仅支持图片格式", + ) 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() diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 51f37e7b..ee604b63 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -274,6 +274,27 @@ async def test_dev_login_allows_plaintext_only_in_development(client_and_db): assert resp.json()["access_token"] +@pytest.mark.asyncio +async def test_avatar_upload_rejects_non_image_files(client_and_db): + client, _ = client_and_db + original_env = settings.ENV + settings.ENV = "development" + try: + login_resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"}) + finally: + settings.ENV = original_env + + token = login_resp.json()["access_token"] + resp = await client.post( + "/api/v1/auth/me/avatar", + files={"file": ("avatar.txt", b"not an image", "text/plain")}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 400 + assert resp.json()["detail"] == "头像仅支持图片格式" + + @pytest.mark.asyncio async def test_dev_login_is_disabled_outside_development(client_and_db): client, _ = client_and_db @@ -357,4 +378,3 @@ async def test_login_challenge_cannot_be_reused(client_and_db): assert first.status_code == 200 assert second.status_code == 401 - diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index ed876a9e..31bcb742 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -204,6 +204,25 @@ + + + + @@ -931,4 +979,18 @@ const onCommand = (cmd: string) => { background-color: var(--el-color-primary-light-9); font-weight: 600; } + +:global(.profile-settings-dialog) { + border-radius: 16px; + overflow: hidden; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26); +} + +:global(.profile-settings-dialog .el-dialog__header) { + display: none; +} + +:global(.profile-settings-dialog .el-dialog__body) { + padding: 0; +} diff --git a/frontend/src/components/LayoutBreadcrumb.test.ts b/frontend/src/components/LayoutBreadcrumb.test.ts index 6fb0f53c..26e15a71 100644 --- a/frontend/src/components/LayoutBreadcrumb.test.ts +++ b/frontend/src/components/LayoutBreadcrumb.test.ts @@ -52,4 +52,19 @@ describe("Layout breadcrumbs", () => { expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)"); expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout'); }); + + it("opens profile settings as a desktop-style dialog instead of navigating away", () => { + const source = readLayout(); + + expect(source).toContain('v-model="profileDialogVisible"'); + expect(source).toContain('class="profile-settings-dialog"'); + expect(source).toContain(':close-on-click-modal="false"'); + expect(source).toContain(':close-on-press-escape="false"'); + expect(source).toContain('@close-request="requestProfileDialogClose"'); + expect(source).toContain('@dirty-change="profileDialogDirty = $event"'); + expect(source).toContain('@saved="profileDialogVisible = false"'); + expect(source).toContain("profileDialogVisible.value = true"); + expect(source).toContain("confirmDiscardProfileChanges"); + expect(source).not.toContain('router.push("/profile")'); + }); }); diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 0a24648c..647d4bcd 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -950,8 +950,7 @@ export const TEXT = { default: "当前角色无权执行该操作", }, profile: { - title: "个人设置", - subtitle: "更新个人信息或修改登录密码", + title: "个人中心", uploadAvatar: "上传头像", currentPassword: "当前密码", currentPasswordHint: "修改密码时必填", @@ -965,6 +964,7 @@ export const TEXT = { updateSuccess: "已更新", updateFailed: "更新失败", avatarUpdated: "头像已更新", + avatarTypeInvalid: "头像仅支持图片格式", avatarUploadFailed: "头像上传失败", }, }, diff --git a/frontend/src/views/ProfileSettings.test.ts b/frontend/src/views/ProfileSettings.test.ts index 5dbc61d8..36b6c710 100644 --- a/frontend/src/views/ProfileSettings.test.ts +++ b/frontend/src/views/ProfileSettings.test.ts @@ -8,9 +8,40 @@ describe("profile settings autocomplete", () => { it("disables browser autofill for profile name, department, and current password", () => { const source = readProfileView(); - expect(source).toContain(''); + expect(source).toContain(''); expect(source).toContain('autocomplete="off"'); expect(source).toContain('autocomplete="new-password"'); expect(source).toContain('name="ctms-profile-current-password"'); }); }); + +describe("profile settings layout", () => { + it("uses a settings-window layout, grouped sections, and aligned actions", () => { + const source = readProfileView(); + + expect(source).not.toContain(" { + it("limits avatar uploads to supported image MIME types", () => { + const source = readProfileView(); + + expect(source).toContain('accept="image/png,image/jpeg,image/gif,image/webp"'); + expect(source).toContain(':before-upload="beforeAvatarUpload"'); + expect(source).toContain('const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])'); + expect(source).toContain("avatarAcceptedTypes.has(file.type)"); + expect(source).toContain("TEXT.modules.profile.avatarTypeInvalid"); + }); +}); diff --git a/frontend/src/views/ProfileSettings.vue b/frontend/src/views/ProfileSettings.vue index d2806cad..0ee74aa4 100644 --- a/frontend/src/views/ProfileSettings.vue +++ b/frontend/src/views/ProfileSettings.vue @@ -1,70 +1,103 @@ diff --git a/frontend/src/views/admin/PermissionManagement.test.ts b/frontend/src/views/admin/PermissionManagement.test.ts index f53a1243..c665620c 100644 --- a/frontend/src/views/admin/PermissionManagement.test.ts +++ b/frontend/src/views/admin/PermissionManagement.test.ts @@ -151,6 +151,13 @@ describe("permission management custom roles", () => { expect(source).not.toContain("project.members.manage"); }); + it("shows member names without avatar icons in the member management table", () => { + const source = readSource(); + + expect(source).not.toContain("member-avatar"); + expect(source).not.toContain("memberInitial(row.full_name)"); + }); + it("uses QA and CTA as preset project permission role keys", () => { const source = readSource(); diff --git a/frontend/src/views/admin/PermissionManagement.vue b/frontend/src/views/admin/PermissionManagement.vue index 85418db5..d0f6099c 100644 --- a/frontend/src/views/admin/PermissionManagement.vue +++ b/frontend/src/views/admin/PermissionManagement.vue @@ -84,7 +84,6 @@ @@ -1782,21 +1781,6 @@ onMounted(async () => { .member-name-cell { display: flex; align-items: center; - gap: 10px; -} - -.member-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 8px; - background: linear-gradient(135deg, #6366f1, #8b5cf6); - color: #fff; - font-size: 13px; - font-weight: 600; - flex-shrink: 0; } .member-name { diff --git a/frontend/src/views/admin/Users.test.ts b/frontend/src/views/admin/Users.test.ts index 226b7350..b72bb049 100644 --- a/frontend/src/views/admin/Users.test.ts +++ b/frontend/src/views/admin/Users.test.ts @@ -32,6 +32,14 @@ describe("Admin users table layout", () => { expect(viewSource).toContain("width: 28px;"); expect(viewSource).toContain("gap: 4px;"); }); + + it("shows names without avatar icons in the account management table", () => { + const viewSource = readUsersView(); + + expect(viewSource).not.toContain("user-avatar"); + expect(viewSource).not.toContain("getInitials(scope.row.full_name)"); + expect(viewSource).not.toContain("linear-gradient(135deg, #3f8f6b, #2d7a5a)"); + }); }); describe("Admin users summary header", () => { diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 03f23c1f..cdecea62 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -87,9 +87,6 @@