diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 43f650a..1337be0 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,18 +1,22 @@ from __future__ import annotations +import base64 +import logging from typing import Literal, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile from pydantic import BaseModel from sqlmodel import Session, select from app.db import get_session -from app.db.models import User, utc_now +from app.db.models import User, UserAvatar, utc_now from app.security.auth import create_access_token, get_current_user, hash_password, verify_password from app.security.permissions import MEMBER_ROLE, is_admin_user from app.security.tenant import ensure_tenant +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -44,10 +48,17 @@ class UserRead(BaseModel): display_name: Optional[str] = None role: Literal["admin", "member"] source: str = "web" + # 仅 /me 与 /login 带出:头像资源指针(存在性标识),不内联二进制—— + # 完整 data_url 可达 2.67MB,内联会把登录/会话刷新响应与前端 localStorage 撑爆 + avatar_url: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None +class AvatarRead(BaseModel): + avatar_url: str + + class LoginResponse(BaseModel): token: str user: UserRead @@ -66,12 +77,106 @@ def login(request: LoginRequest, db: Session = Depends(get_session)) -> LoginRes if not user or not verify_password(request.password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid username or password") - return LoginResponse(token=create_access_token(user), user=_user_read(user)) + return LoginResponse( + token=create_access_token(user), + user=_user_read(user, _avatar_pointer_for(db, user.id)), + ) @router.get("/me", response_model=UserRead) -def me(user: User = Depends(get_current_user)) -> UserRead: - return _user_read(user) +def me(user: User = Depends(get_current_user), db: Session = Depends(get_session)) -> UserRead: + return _user_read(user, _avatar_pointer_for(db, user.id)) + + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 +# multipart 边界与头部开销的上限估计:Content-Length 预检放行正常图片,拦截明显超限请求 +_AVATAR_MULTIPART_OVERHEAD = 64 * 1024 +# 头像资源路径:login/me 返回的 avatar_url 即此指针,前端凭它用认证请求拉取字节 +AVATAR_RESOURCE_PATH = "/api/auth/me/avatar" +# 头像类型嗅探:以实际字节头为准(防伪装 content-type),仅 png/jpeg/webp/gif +_AVATAR_MAGIC: tuple[tuple[bytes, str], ...] = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), +) + + +def _sniff_avatar_content_type(data: bytes) -> Optional[str]: + """按字节头识别图片类型,返回规范 content-type;非支持图片返回 None。""" + for magic, content_type in _AVATAR_MAGIC: + if data.startswith(magic): + return content_type + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + +@router.get("/me/avatar") +def get_my_avatar( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + """头像资源端点:返回图片字节(不内联进 login/me,避免大字段进会话存储)。""" + avatar = db.get(UserAvatar, current_user.id) + if not avatar: + raise HTTPException(status_code=404, detail="Avatar not found") + parsed = _parse_avatar_data_url(avatar.data_url) + if not parsed: + logger.warning("用户 %s 的头像数据损坏,按不存在处理", current_user.id) + raise HTTPException(status_code=404, detail="Avatar not found") + data, content_type = parsed + return Response( + content=data, + media_type=content_type, + headers={"Cache-Control": "private, no-cache"}, + ) + + +@router.put("/me/avatar", response_model=AvatarRead) +async def update_my_avatar( + request: Request, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> AvatarRead: + """上传/覆盖当前用户头像:multipart 单文件,图片 ≤2MB,以 data_url 存库(upsert)。""" + # 先按 Content-Length 快速拒绝明显超限的请求,避免把超大请求体完整读入内存 + content_length = request.headers.get("content-length") + if content_length and content_length.isdigit(): + if int(content_length) > MAX_AVATAR_BYTES + _AVATAR_MULTIPART_OVERHEAD: + raise HTTPException(status_code=413, detail="头像文件超过 2MB 大小限制") + # 限量读取(最多 MAX+1 字节)做硬性兜底,覆盖 Content-Length 缺失或虚报的情况 + data = await file.read(MAX_AVATAR_BYTES + 1) + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException(status_code=413, detail="头像文件超过 2MB 大小限制") + content_type = _sniff_avatar_content_type(data) + if not content_type: + raise HTTPException(status_code=400, detail="仅支持 png/jpeg/webp/gif 格式的图片") + data_url = f"data:{content_type};base64,{base64.b64encode(data).decode('ascii')}" + avatar = db.get(UserAvatar, current_user.id) + if avatar: + avatar.data_url = data_url + avatar.updated_at = utc_now() + else: + avatar = UserAvatar(user_id=current_user.id, data_url=data_url) + db.add(avatar) + db.commit() + # 响应同样不内联二进制:返回资源指针,前端经 GET /me/avatar 拉取字节 + return AvatarRead(avatar_url=AVATAR_RESOURCE_PATH) + + +@router.delete("/me/avatar", status_code=204) +def delete_my_avatar( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + """删除当前用户头像(无头像时幂等 204)。""" + avatar = db.get(UserAvatar, current_user.id) + if avatar: + db.delete(avatar) + db.commit() + return Response(status_code=204) @router.post("/users", response_model=UserRead) @@ -163,12 +268,16 @@ def delete_user( raise HTTPException(status_code=404, detail="Account not found") if user.id == current_user.id or is_admin_user(user): raise HTTPException(status_code=400, detail="Administrator account cannot be deleted") + # 头像为独立小表、无外键级联:显式随用户删除,避免残留孤儿记录 + avatar = db.get(UserAvatar, user_id) + if avatar: + db.delete(avatar) db.delete(user) db.commit() return {"ok": True} -def _user_read(user: User) -> UserRead: +def _user_read(user: User, avatar_url: Optional[str] = None) -> UserRead: return UserRead( id=user.id, tenant_id=user.tenant_id, @@ -176,11 +285,30 @@ def _user_read(user: User) -> UserRead: display_name=user.display_name, role=user.role, source=user.source, + avatar_url=avatar_url, created_at=user.created_at.isoformat() if user.created_at else None, updated_at=user.updated_at.isoformat() if user.updated_at else None, ) +def _avatar_pointer_for(db: Session, user_id: str) -> Optional[str]: + """头像存在性指针:有头像返回资源路径,无返回 None(绝不内联二进制)。""" + avatar = db.get(UserAvatar, user_id) + return AVATAR_RESOURCE_PATH if avatar else None + + +def _parse_avatar_data_url(data_url: str) -> Optional[tuple[bytes, str]]: + """拆解 data:image/*;base64,... 为(字节, content-type);非法返回 None。""" + try: + meta, payload = data_url.split(",", 1) + content_type = meta.removeprefix("data:").removesuffix(";base64") + if not content_type.startswith("image/"): + return None + return base64.b64decode(payload), content_type + except (ValueError, TypeError): + return None + + def _require_admin(user: User, tenant_id: str) -> None: if not is_admin_user(user): raise HTTPException(status_code=403, detail="Only administrator can manage accounts") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index e3a5957..df0ba06 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -41,6 +41,19 @@ class User(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_now) +class UserAvatar(SQLModel, table=True): + """用户头像:小图以 data_url 直接存库(与聊天附件内联方式一致), + + 独立小表避免 users 热表膨胀;create_all 建表,无需 ALTER。 + """ + + __tablename__ = "user_avatars" + + user_id: str = Field(primary_key=True) + data_url: str + updated_at: datetime = Field(default_factory=utc_now) + + class Skill(SQLModel, table=True): __tablename__ = "skills" __table_args__ = (UniqueConstraint("tenant_id", "skill_id", name="uq_skill_tenant_skill_id"),) diff --git a/backend/tests/test_user_avatar.py b/backend/tests/test_user_avatar.py new file mode 100644 index 0000000..e670048 --- /dev/null +++ b/backend/tests/test_user_avatar.py @@ -0,0 +1,263 @@ +"""用户头像上传:data_url 存库、字节头嗅探、大小限制、me/login 带出。""" + +import base64 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +import app.api.auth as auth_api +from app.db import get_session +from app.db.models import Tenant, User, UserAvatar +from app.security.auth import create_access_token, hash_password + +_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 +_GIF_BYTES = b"GIF89a" + b"\x00" * 32 +_WEBP_BYTES = b"RIFF" + b"\x24\x00\x00\x00" + b"WEBP" + b"VP8 " + b"\x00" * 16 + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _make_client(engine): + app = FastAPI() + app.include_router(auth_api.router) + + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + return TestClient(app) + + +def _seed_user(engine, *, password: str = "secret") -> User: + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + user = User( + id="user_web", + tenant_id="tenant_demo", + username="zhangsan", + display_name="张三", + password_hash=hash_password(password), + ) + db.add(user) + db.commit() + db.refresh(user) + db.expunge(user) + return user + + +def _auth(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user)}"} + + +def _upload( + client: TestClient, + user: User, + data: bytes, + *, + content_type: str = "image/png", + filename: str = "avatar.png", +): + return client.put( + "/api/auth/me/avatar", + files={"file": (filename, data, content_type)}, + headers=_auth(user), + ) + + +def test_upload_avatar_and_me_login_carry_avatar_pointer() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, _PNG_BYTES) + assert response.status_code == 200 + # 响应不内联二进制:avatar_url 一律为资源指针 + assert response.json()["avatar_url"] == "/api/auth/me/avatar" + expected_data_url = f"data:image/png;base64,{base64.b64encode(_PNG_BYTES).decode('ascii')}" + + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.status_code == 200 + assert me.json()["avatar_url"] == "/api/auth/me/avatar" + + login = client.post( + "/api/auth/login", + json={"tenant_id": "tenant_demo", "username": "zhangsan", "password": "secret"}, + ) + assert login.status_code == 200 + assert login.json()["user"]["avatar_url"] == "/api/auth/me/avatar" + + with Session(engine) as db: + row = db.get(UserAvatar, user.id) + # 完整 data_url 只存在于库里 + assert row is not None and row.data_url == expected_data_url + + +def test_get_avatar_returns_image_bytes() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + response = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/png") + assert response.content == _PNG_BYTES + assert "no-cache" in response.headers.get("cache-control", "") + + +def test_get_avatar_404_without_avatar_and_requires_auth() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert client.get("/api/auth/me/avatar", headers=_auth(user)).status_code == 404 + assert client.get("/api/auth/me/avatar").status_code == 401 + + +def test_upload_avatar_overwrites_existing() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + replaced = _upload(client, user, _GIF_BYTES, content_type="image/gif", filename="avatar.gif") + assert replaced.status_code == 200 + assert replaced.json()["avatar_url"] == "/api/auth/me/avatar" + + fetched = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert fetched.content == _GIF_BYTES + assert fetched.headers["content-type"].startswith("image/gif") + + with Session(engine) as db: + rows = db.exec(select(UserAvatar)).all() + # upsert:仍只有一行,内容为后传的 gif + assert len(rows) == 1 + assert rows[0].data_url.startswith("data:image/gif;base64,") + + +def test_upload_accepts_webp_by_magic() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, _WEBP_BYTES, content_type="image/webp", filename="a.webp") + assert response.status_code == 200 + assert response.json()["avatar_url"] == "/api/auth/me/avatar" + fetched = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert fetched.headers["content-type"].startswith("image/webp") + assert fetched.content == _WEBP_BYTES + + +def test_delete_avatar_is_idempotent() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.json()["avatar_url"] is None + assert client.get("/api/auth/me/avatar", headers=_auth(user)).status_code == 404 + with Session(engine) as db: + assert db.exec(select(UserAvatar)).all() == [] + # 再删一次仍 204 + assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 + + +def test_delete_user_cascades_avatar() -> None: + """管理员删除用户:其头像记录一并删除,不留孤儿行。""" + engine = _test_engine() + member = _seed_user(engine) + with Session(engine) as db: + admin = User( + id="user_admin", + tenant_id="tenant_demo", + username="admin2", + display_name="管理员", + password_hash=hash_password("secret"), + role="admin", + ) + db.add(admin) + db.commit() + db.refresh(admin) + db.expunge(admin) + client = _make_client(engine) + + assert _upload(client, member, _PNG_BYTES).status_code == 200 + deleted = client.delete( + "/api/auth/users/user_web?tenant_id=tenant_demo", headers=_auth(admin) + ) + assert deleted.status_code == 200 + with Session(engine) as db: + assert db.get(User, "user_web") is None + assert db.get(UserAvatar, "user_web") is None + + +def test_upload_rejects_non_image() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload( + client, user, b"plain text content", content_type="text/plain", filename="a.txt" + ) + assert response.status_code == 400 + + +def test_upload_rejects_oversize() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + oversize = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024) + response = _upload(client, user, oversize) + assert response.status_code == 413 + + +def test_upload_rejects_oversize_by_content_length() -> None: + """请求体 Content-Length 明显超限(>2MB+multipart 开销):预检直接 413,不读完整内容。""" + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + oversize = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024 + 128 * 1024) + response = _upload(client, user, oversize) + assert response.status_code == 413 + + +def test_upload_rejects_fake_content_type() -> None: + """声明 image/png 但字节头不是图片:按内容嗅探拒绝。""" + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, b"definitely not an image", content_type="image/png") + assert response.status_code == 400 + + +def test_me_and_login_without_avatar_return_none() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.status_code == 200 + assert me.json()["avatar_url"] is None + + login = client.post( + "/api/auth/login", + json={"tenant_id": "tenant_demo", "username": "zhangsan", "password": "secret"}, + ) + assert login.status_code == 200 + assert login.json()["user"]["avatar_url"] is None diff --git a/frontend-enterprise/src/auth.ts b/frontend-enterprise/src/auth.ts index 33eff37..afc1ffc 100644 --- a/frontend-enterprise/src/auth.ts +++ b/frontend-enterprise/src/auth.ts @@ -4,6 +4,7 @@ export type EnterpriseAuthUser = { username: string; display_name?: string; role: 'admin' | 'member'; + avatar_url?: string; }; export type EnterpriseAuthSession = { @@ -18,7 +19,21 @@ export function getEnterpriseAuthSession(): EnterpriseAuthSession | null { } export function setEnterpriseAuthSession(session: EnterpriseAuthSession): void { - window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(session)); + try { + window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(session)); + } catch { + // 存储超限等异常(极端情况):降级为不带头像字段的最小会话再试一次 + try { + const minimal: EnterpriseAuthSession = { + ...session, + user: { ...session.user, avatar_url: undefined }, + }; + window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(minimal)); + } catch { + // 抛出真实原因,避免被登录流程误报为账号/密码错误 + throw new Error('浏览器存储空间不足,请清理站点数据后重试'); + } + } } export function clearEnterpriseAuthSession(): void { diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 6404711..28a5e22 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { DropdownMenu, @@ -6,12 +6,36 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui'; +import { Button as UIButton } from '@/components/ui/button'; +import { notify } from '@/components/ui/app-toast'; import { cn } from '@/lib/utils'; import IconChevronDown from '../assets/icons/chevron-down.svg?react'; +import IconEdit from '../assets/icons/edit.svg?react'; import IconLogout from '../assets/icons/logout.svg?react'; +import { api } from '../api/client'; +import { + getEnterpriseAuthSession, + setEnterpriseAuthSession, + type EnterpriseAuthUser, +} from '../auth'; import LanguageSwitcher from './LanguageSwitcher'; +/** 只允许 http/https/data:image/blob 协议的图片地址,其余一律视为无效。 */ +function safeImageUrl(value: string): string { + const text = value.trim(); + if (!text) return ''; + try { + const parsed = new URL(text, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href; + if (parsed.protocol === 'blob:') return parsed.href; + if (parsed.protocol === 'data:' && /^data:image\//i.test(text)) return text; + } catch { + // 非法 URL 视为无效 + } + return ''; +} + export type AppHeaderProps = { /** * Page-specific content rendered on the left side of the header. When @@ -51,7 +75,137 @@ export default function AppHeader({ userName, className, }: AppHeaderProps) { - const initial = userName?.trim()?.[0]?.toUpperCase(); + const [user, setUser] = useState(() => getEnterpriseAuthSession()?.user); + const [uploadPreviewUrl, setUploadPreviewUrl] = useState(''); + const uploadPreviewUrlRef = useRef(''); + const [avatarBlobUrl, setAvatarBlobUrl] = useState(''); + const avatarBlobUrlRef = useRef(''); + const [avatarSaving, setAvatarSaving] = useState(false); + const fileInputRef = useRef(null); + + const displayName = user?.display_name || user?.username || ''; + const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); + const isAdmin = user?.role === 'admin'; + // avatar_url 是资源指针(非图片地址):渲染只用预览/已拉取的 blob URL + const avatarUrl = uploadPreviewUrl || avatarBlobUrl; + // 仅放行 http/https/data:image/blob 协议,阻止 javascript: 等可执行协议注入 + const safeAvatarUrl = safeImageUrl(avatarUrl); + + // blob URL 由 ref 跟踪:替换/清除/组件卸载时都能 revoke 到最新值,不受闭包快照影响 + function replaceTrackedUrl(ref: { current: string }, set: (v: string) => void, next: string) { + const prev = ref.current; + if (prev && prev !== next) URL.revokeObjectURL(prev); + ref.current = next; + set(next); + } + + function replaceUploadPreview(next: string) { + replaceTrackedUrl(uploadPreviewUrlRef, setUploadPreviewUrl, next); + } + + function replaceAvatarBlob(next: string) { + replaceTrackedUrl(avatarBlobUrlRef, setAvatarBlobUrl, next); + } + + function clearUploadPreview() { + replaceUploadPreview(''); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + // 头像二进制不随 login/me 内联:凭指针用认证请求拉字节,转 blob URL 渲染 + async function loadAvatar() { + const session = getEnterpriseAuthSession(); + if (!session?.token || !session.user?.avatar_url) { + replaceAvatarBlob(''); + return; + } + try { + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch(`${apiBase}/api/auth/me/avatar`, { + headers: { Authorization: `Bearer ${session.token}` }, + }); + if (!response.ok) { + replaceAvatarBlob(''); + return; + } + const blob = await response.blob(); + replaceAvatarBlob(URL.createObjectURL(blob)); + } catch { + // 头像加载失败不阻断,回退为首字母 + } + } + + const avatarPointer = user?.avatar_url || ''; + const userId = user?.id || ''; + useEffect(() => { + void loadAvatar(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [avatarPointer, userId]); + + useEffect( + () => () => { + if (uploadPreviewUrlRef.current) URL.revokeObjectURL(uploadPreviewUrlRef.current); + if (avatarBlobUrlRef.current) URL.revokeObjectURL(avatarBlobUrlRef.current); + }, + [], + ); + + async function refreshSessionUser() { + const session = getEnterpriseAuthSession(); + if (!session?.token) return; + try { + const fresh = await api.get('/api/auth/me'); + setEnterpriseAuthSession({ token: session.token, user: fresh }); + setUser(fresh); + } catch { + // 头像操作已成功时会话刷新失败不阻断,下次登录/刷新自然同步 + } + } + + // 选图即传:本地预览乐观渲染,成功后刷新会话;失败回滚并提示 + async function pickAvatar(file: File | null) { + if (!file || avatarSaving) return; + clearUploadPreview(); + const objectUrl = URL.createObjectURL(file); + replaceUploadPreview(objectUrl); + setAvatarSaving(true); + try { + const session = getEnterpriseAuthSession(); + const form = new FormData(); + form.append('file', file); + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch(`${apiBase}/api/auth/me/avatar`, { + method: 'PUT', + headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {}, + body: form, + }); + if (!response.ok) throw new Error('上传头像失败'); + notify.success('头像已更新'); + await refreshSessionUser(); + // 覆盖上传时指针字符串不变,effect 不会重触发,显式重拉头像字节 + await loadAvatar(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '上传头像失败'); + } finally { + setAvatarSaving(false); + clearUploadPreview(); + } + } + + async function removeAvatar() { + if (avatarSaving) return; + setAvatarSaving(true); + try { + await api.delete('/api/auth/me/avatar'); + notify.success('头像已移除'); + await refreshSessionUser(); + await loadAvatar(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '移除头像失败'); + } finally { + setAvatarSaving(false); + } + } const leftContent = left ?? ( (title !== undefined || description !== undefined) ? ( @@ -78,7 +232,11 @@ export default function AppHeader({ className="flex h-[32px] shrink-0 items-center gap-[8px] rounded-[10px] pl-[4px] pr-[8px] outline-none" > - {initial ?? '--'} + {safeAvatarUrl ? ( + + ) : ( + (initial ?? '--') + )} @@ -86,6 +244,92 @@ export default function AppHeader({ align="end" className="w-fit min-w-0 rounded-[14px] border-0 bg-white p-[6px] shadow-[0px_16px_15px_rgba(0,0,0,0.1)] ring-0 [--accent:#F6F6F6] [--accent-foreground:#18181A]" > + {user && ( + <> +
+
+
+ + {avatarSaving && ( + + + + )} + {!avatarSaving && ( + + + + )} +
+
+ + {displayName} + + {user.username && user.username !== displayName && ( + + @{user.username} + + )} +
+
+
+
+ + {isAdmin ? '管理员' : '成员'} + + {user.avatar_url && ( + + )} +
+ {isAdmin && ( + + {user.tenant_id} + + )} +
+
+
+ + )} onLogout?.()} className="h-[36px] cursor-pointer gap-2 rounded-[10px] px-[12px] text-[14px] text-[#464C5E]" @@ -97,6 +341,15 @@ export default function AppHeader({ )}
+ {/* 文件 input 常驻在 header 根部(不在下拉菜单内),菜单关闭也不会被卸载; + 选图即传:本地预览乐观渲染,上传成功刷新会话,失败回滚 */} + pickAvatar(event.target.files?.[0] || null)} + /> ); } diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 25e2645..58359a8 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -1993,6 +1993,12 @@ "待配置": "Pending configuration", "收到确认": "Receipt reaction", "确认清理": "Receipt cleanup", + "移除头像": "Remove Avatar", + "头像已更新": "Avatar updated", + "头像已移除": "Avatar removed", + "上传头像失败": "Failed to upload avatar", + "移除头像失败": "Failed to remove avatar", + "浏览器存储空间不足,请清理站点数据后重试": "Browser storage is full. Clear site data and try again.", "企业: {1}": "Enterprise: {1}", "token 已失效,请重新扫码": "Token expired. Please scan the QR code again", "当前未连接,请检查凭证或网络": "Disconnected. Check credentials or network", @@ -2057,5 +2063,6 @@ "五": "Fri", "六": "Sat", "…等": "…and ", - "还有": "+ " + "还有": "+ ", + "成员": "Member" }