feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)

Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
ddcat666
2026-10-13 05:31:30 +08:00
committed by GitHub
parent e70fe1b9c9
commit 4c1d2b4012
83 changed files with 5836 additions and 328 deletions

View File

@@ -1,164 +1,2 @@
import { useRef, useState, type PointerEvent } from "react";
import { Button } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan";
import { resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection";
import { useImageToolSource } from "./use-image-tool-source";
export type CanvasImageCropRect = ImageCropRect;
type CropProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void };
const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
const grips: { name: string; vector: CropGrip }[] = [
{ name: "nw", vector: [-1, -1] },
{ name: "n", vector: [0, -1] },
{ name: "ne", vector: [1, -1] },
{ name: "w", vector: [-1, 0] },
{ name: "e", vector: [1, 0] },
{ name: "sw", vector: [-1, 1] },
{ name: "s", vector: [0, 1] },
{ name: "se", vector: [1, 1] },
];
type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null };
export function CanvasNodeCropDialog(props: CropProps) {
return props.open && props.dataUrl ? <CropEditor key={props.dataUrl} {...props} /> : null;
}
function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) {
const { size, error } = useImageToolSource(dataUrl);
const [selection, setSelection] = useState(initialSelection);
const [locked, setLocked] = useState(false);
const [preset, setPreset] = useState("");
const stage = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const output = size ? resolveCropPixels(size, selection) : null;
const begin = (event: PointerEvent<HTMLElement>, grip: CropGrip | null) => {
if (event.button !== 0 || gesture.current || !stage.current || !size) return;
const bounds = stage.current.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip };
};
const finish = (event: PointerEvent<HTMLElement>) => {
if (event.pointerId !== gesture.current?.pointer) return;
gesture.current = null;
if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId);
};
const choose = (value: string) => {
if (!size) return;
const ratio =
value === "原图"
? 1
: (() => {
const [w, h] = value.split(":").map(Number);
return (w * size.height) / (h * size.width);
})();
setSelection((box) => selectAspect(box, ratio));
setPreset(value);
setLocked(true);
};
return (
<AppModal open centered width={780} title="裁剪图片" onCancel={onClose} onOk={() => size && onConfirm(selection)} okText="确认裁剪" okButtonProps={{ disabled: !size }}>
<div className="space-y-4">
<div className="flex justify-center">
<div
ref={stage}
className="relative inline-block max-w-full touch-none select-none overflow-hidden rounded-lg"
onPointerMove={(event) => {
const drag = gesture.current;
if (!drag || drag.pointer !== event.pointerId) return;
const dx = (event.clientX - drag.origin[0]) / drag.display[0];
const dy = (event.clientY - drag.origin[1]) / drag.display[1];
setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, locked) : translateSelection(drag.selection, dx, dy));
}}
onPointerUp={finish}
onPointerCancel={finish}
onLostPointerCapture={finish}
>
<img src={dataUrl} alt="待裁剪图片" draggable={false} className="block max-h-[60vh] max-w-full" />
{size ? (
<div
role="group"
aria-label="裁剪区域"
tabIndex={0}
className="absolute cursor-move border border-white outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: `${selection.x * 100}%`, top: `${selection.y * 100}%`, width: `${selection.width * 100}%`, height: `${selection.height * 100}%`, boxShadow: "0 0 0 100vmax rgb(0 0 0 / 55%)" }}
onPointerDown={(event) => begin(event, null)}
onKeyDown={(event) => {
const vectors: Record<string, readonly [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const direction = vectors[event.key];
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
setSelection((box) => translateSelection(box, (direction[0] * step) / size.width, (direction[1] * step) / size.height));
}}
>
<svg viewBox="0 0 3 3" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<path d="M1 0V3M2 0V3M0 1H3M0 2H3" stroke="white" strokeOpacity={0.5} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</svg>
{grips.map(({ name, vector }) => (
<button
type="button"
key={name}
aria-label={`调整裁剪框 ${name}`}
className="absolute size-3 rounded-full border border-black bg-white"
style={{ left: `${(vector[0] + 1) * 50}%`, top: `${(vector[1] + 1) * 50}%`, transform: "translate(-50%, -50%)", cursor: `${name}-resize` }}
onPointerDown={(event) => begin(event, vector)}
onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
const dx = (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0) / size.width;
const dy = (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0) / size.height;
setSelection((box) => resizeSelection(box, vector, dx, dy, locked));
}}
/>
))}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="常用裁剪比例">
{["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => (
<Button key={value} size="small" disabled={!size} aria-pressed={preset === value} onClick={() => choose(value)}>
{value}
</Button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<output>{output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…"}</output>
<div className="flex gap-2">
<Button
disabled={!size}
aria-pressed={locked}
onClick={() => {
setLocked(!locked);
setPreset("");
}}
>
{locked ? "解除比例锁定" : "锁定当前比例"}
</Button>
<Button
onClick={() => {
setSelection(initialSelection);
setLocked(false);
setPreset("");
}}
>
重置
</Button>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
</AppModal>
);
}
export { ImageCropDialog as CanvasNodeCropDialog } from "@/components/image-crop-dialog";
export type { ImageCropRect as CanvasImageCropRect } from "@/components/image-crop-dialog";

View File

@@ -1,21 +1 @@
import { useEffect, useState } from "react";
import { loadRasterImage } from "@/lib/image-raster";
import type { PixelSize } from "@/lib/canvas/image-operation-plan";
export function useImageToolSource(source: string) {
const [result, setResult] = useState<{ source: string; size: PixelSize | null; error: string }>({ source, size: null, error: "" });
useEffect(() => {
const request = new AbortController();
setResult({ source, size: null, error: "" });
void loadRasterImage(source, request.signal).then(
(image) => {
if (!request.signal.aborted) setResult({ source, size: { width: image.naturalWidth, height: image.naturalHeight }, error: "" });
},
(error: unknown) => {
if (!request.signal.aborted) setResult({ source, size: null, error: error instanceof Error ? error.message : "图片读取失败" });
},
);
return () => request.abort();
}, [source]);
return result.source === source ? result : { source, size: null, error: "" };
}
export { useImageToolSource } from "@/hooks/use-image-tool-source";

View File

@@ -0,0 +1,190 @@
import { useRef, useState, type PointerEvent } from "react";
import { Button } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan";
import { centeredAspectSelection, resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection";
import { useImageToolSource } from "@/hooks/use-image-tool-source";
export type { ImageCropRect } from "@/lib/canvas/image-operation-plan";
export type ImageCropDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: ImageCropRect) => void; fixedAspect?: number; title?: string; confirmLoading?: boolean };
const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
const grips: { name: string; vector: CropGrip }[] = [
{ name: "nw", vector: [-1, -1] },
{ name: "n", vector: [0, -1] },
{ name: "ne", vector: [1, -1] },
{ name: "w", vector: [-1, 0] },
{ name: "e", vector: [1, 0] },
{ name: "sw", vector: [-1, 1] },
{ name: "s", vector: [0, 1] },
{ name: "se", vector: [1, 1] },
];
type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null };
export function ImageCropDialog(props: ImageCropDialogProps) {
return props.open && props.dataUrl ? <CropEditor key={`${props.dataUrl}:${props.fixedAspect ?? "free"}`} {...props} /> : null;
}
function CropEditor({ dataUrl, onClose, onConfirm, fixedAspect, title = "裁剪图片", confirmLoading = false }: ImageCropDialogProps) {
const { size, error } = useImageToolSource(dataUrl);
const [selected, setSelection] = useState<ImageCropRect | null>(null);
const selection = selected ?? (size && fixedAspect !== undefined ? centeredAspectSelection(size, fixedAspect) : initialSelection);
const [locked, setLocked] = useState(false);
const aspectLocked = fixedAspect !== undefined || locked;
const [preset, setPreset] = useState("");
const stage = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const output = size ? resolveCropPixels(size, selection) : null;
const outputLabel = output && fixedAspect === 1 ? `输出 ${Math.min(output.width, output.height)} × ${Math.min(output.width, output.height)} px` : output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…";
const begin = (event: PointerEvent<HTMLElement>, grip: CropGrip | null) => {
if (confirmLoading || event.button !== 0 || gesture.current || !stage.current || !size) return;
const bounds = stage.current.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip };
};
const finish = (event: PointerEvent<HTMLElement>) => {
if (event.pointerId !== gesture.current?.pointer) return;
gesture.current = null;
if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId);
};
const choose = (value: string) => {
if (!size) return;
const ratio =
value === "原图"
? 1
: (() => {
const [w, h] = value.split(":").map(Number);
return (w * size.height) / (h * size.width);
})();
setSelection((box) => selectAspect(box ?? selection, ratio));
setPreset(value);
setLocked(true);
};
return (
<AppModal
open
centered
width={780}
title={title}
onCancel={() => {
if (!confirmLoading) onClose();
}}
onOk={() => {
if (size && !confirmLoading) onConfirm(selection);
}}
okText="确认裁剪"
confirmLoading={confirmLoading}
okButtonProps={{ disabled: !size || !!error || confirmLoading }}
cancelButtonProps={{ disabled: confirmLoading }}
closable={!confirmLoading}
keyboard={!confirmLoading}
mask={{ closable: !confirmLoading }}
>
<div className="space-y-4">
<div className="flex justify-center">
<div
ref={stage}
className="relative inline-block max-w-full touch-none select-none overflow-hidden rounded-lg"
onPointerMove={(event) => {
const drag = gesture.current;
if (confirmLoading || !drag || drag.pointer !== event.pointerId) return;
const dx = (event.clientX - drag.origin[0]) / drag.display[0];
const dy = (event.clientY - drag.origin[1]) / drag.display[1];
setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, aspectLocked) : translateSelection(drag.selection, dx, dy));
}}
onPointerUp={finish}
onPointerCancel={finish}
onLostPointerCapture={finish}
>
<img src={dataUrl} alt="待裁剪图片" draggable={false} className="block max-h-[60vh] max-w-full" />
{size ? (
<div
role="group"
aria-label="裁剪区域"
tabIndex={0}
className="absolute cursor-move border border-white outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: `${selection.x * 100}%`, top: `${selection.y * 100}%`, width: `${selection.width * 100}%`, height: `${selection.height * 100}%`, boxShadow: "0 0 0 100vmax rgb(0 0 0 / 55%)" }}
onPointerDown={(event) => begin(event, null)}
onKeyDown={(event) => {
const vectors: Record<string, readonly [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const direction = vectors[event.key];
if (!direction || confirmLoading) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
setSelection((box) => translateSelection(box ?? selection, (direction[0] * step) / size.width, (direction[1] * step) / size.height));
}}
>
<svg viewBox="0 0 3 3" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<path d="M1 0V3M2 0V3M0 1H3M0 2H3" stroke="white" strokeOpacity={0.5} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</svg>
{grips.map(({ name, vector }) => (
<button
type="button"
key={name}
aria-label={`调整裁剪框 ${name}`}
className="absolute size-3 rounded-full border border-black bg-white"
style={{ left: `${(vector[0] + 1) * 50}%`, top: `${(vector[1] + 1) * 50}%`, transform: "translate(-50%, -50%)", cursor: `${name}-resize` }}
onPointerDown={(event) => begin(event, vector)}
onKeyDown={(event) => {
if (confirmLoading || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 10 : 1;
const dx = (event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0) / size.width;
const dy = (event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0) / size.height;
setSelection((box) => resizeSelection(box ?? selection, vector, dx, dy, aspectLocked));
}}
/>
))}
</div>
) : null}
</div>
</div>
{fixedAspect === undefined ? (
<div className="flex flex-wrap gap-2" role="group" aria-label="常用裁剪比例">
{["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => (
<Button key={value} size="small" disabled={!size || confirmLoading} aria-pressed={preset === value} onClick={() => choose(value)}>
{value}
</Button>
))}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<output>{outputLabel}</output>
<div className="flex gap-2">
{fixedAspect === undefined ? (
<Button
disabled={!size || confirmLoading}
aria-pressed={locked}
onClick={() => {
setLocked(!locked);
setPreset("");
}}
>
{locked ? "解除比例锁定" : "锁定当前比例"}
</Button>
) : null}
<Button
disabled={!size || confirmLoading}
onClick={() => {
setSelection(null);
setLocked(false);
setPreset("");
}}
>
重置
</Button>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
</AppModal>
);
}

View File

@@ -1,11 +1,26 @@
import { useEffect, useState } from "react";
import { CircleUserRound } from "lucide-react";
import { Bot, Camera, Cat, CircleUserRound, Clapperboard, Rabbit, Rocket, Sparkles, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { isDefaultUserAvatarID, type DefaultUserAvatarID } from "@/lib/default-user-avatars";
import { resolveUserAvatarURL } from "@/lib/user-avatar-url";
import { apiBaseURL } from "@/services/api/request";
import type { LocalUser } from "@/stores/use-user-store";
export function UserAvatar({ user, className }: { user: LocalUser; className?: string }) {
const defaultAvatarIcons: Record<DefaultUserAvatarID, LucideIcon> = {
person: CircleUserRound,
director: Clapperboard,
camera: Camera,
cat: Cat,
rabbit: Rabbit,
robot: Bot,
rocket: Rocket,
sparkles: Sparkles,
};
export function UserAvatar({ user, className, iconClassName }: { user: LocalUser; className?: string; iconClassName?: string }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
const avatarUrl = user.avatarMode === "default" ? "" : resolveUserAvatarURL(user.avatarUrl, apiBaseURL);
const DefaultAvatarIcon = defaultAvatarIcons[isDefaultUserAvatarID(user.avatarPreset) ? user.avatarPreset : "person"];
useEffect(() => setFailed(false), [avatarUrl]);
@@ -15,7 +30,7 @@ export function UserAvatar({ user, className }: { user: LocalUser; className?: s
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<CircleUserRound className="size-full" aria-hidden />
<DefaultAvatarIcon className={cn("size-full", iconClassName)} aria-hidden />
)}
</span>
);

View File

@@ -25,7 +25,7 @@ export function WorkspaceAccountCard({ onWallet, onNavigate }: { onWallet: () =>
{availableMicrocredits === null ? <Button size="small" loading={refreshing} icon={<RefreshCw />} onClick={() => void refresh()}>刷新余额</Button> : <button type="button" onClick={onWallet}>充值 / 兑换<ArrowUpRight /></button>}
</div> : null}
<nav className="workspace-account-card-actions" aria-label="账户操作">
<Link to="/settings" onClick={onNavigate}><Settings /><span>账户与设置</span><ArrowUpRight /></Link>
<Link to="/settings?section=profile" onClick={onNavigate}><Settings /><span>账户与设置</span><ArrowUpRight /></Link>
{user.role === "admin" ? <Link to="/admin" onClick={onNavigate}><ShieldCheck /><span>管理员后台</span><ArrowUpRight /></Link> : null}
<Button danger type="text" icon={<LogOut />} loading={loggingOut} onClick={() => void handleLogout()}>退出登录</Button>
</nav>

View File

@@ -1,11 +1,12 @@
import { App, Popover } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { ChevronRight, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
import { UserAvatar } from "@/components/layout/user-avatar";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { applyUserSession } from "@/lib/user-session";
@@ -69,7 +70,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
content={(
<div className="w-[232px] py-0.5">
<div className="flex items-center gap-3 border-b border-border/65 px-1 pb-3">
<UserAvatar user={user} className="size-8" />
<SidebarUserAvatar user={user} className="size-8" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
@@ -96,7 +97,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
)}
>
<button type="button" className={cn("app-workspace-account-button flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-surface-hover", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<UserAvatar user={user} className="size-7" />
<SidebarUserAvatar user={user} className="size-7" />
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
{creditsEnabled ? <span className="mt-0.5 block truncate text-[var(--fs-micro)] tabular-nums text-foreground/42">{balance} 积分</span> : null}
@@ -117,21 +118,10 @@ function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
}
function UserAvatar({ user, className }: { user: LocalUser; className: string }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
useEffect(() => setFailed(false), [avatarUrl]);
function SidebarUserAvatar({ user, className }: { user: LocalUser; className: string }) {
return (
<span className={`relative grid shrink-0 place-items-center ${className}`}>
<span className="grid size-full place-items-center overflow-hidden rounded-md bg-transparent text-foreground/55">
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<CircleUserRound className="app-workspace-account-icon" aria-hidden />
)}
</span>
<UserAvatar user={user} className="size-full rounded-md bg-transparent text-foreground/55" iconClassName="size-6 app-workspace-account-icon" />
<IdentityProviderBadge user={user} compact className="absolute -bottom-1 -right-1" />
</span>
);

View File

@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSPrope
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { BrandLogoFrame } from "@/components/brand/brand-logo";
import { UserAvatar } from "@/components/layout/user-avatar";
import { Kbd } from "@/components/ui/base/kbd";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
@@ -62,18 +63,13 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[];
}
function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { collapsed: boolean; user: NonNullable<ReturnType<typeof useUserStore.getState>["user"]> | null; onExpand: () => void; onCollapse: () => void }) {
const [failed, setFailed] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user?.avatarUrl || "") ? user?.avatarUrl : "";
const profileName = user?.displayName || user?.username || "未登录";
useEffect(() => setFailed(false), [avatarUrl]);
if (!user) {
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
}
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />;
return (
@@ -83,7 +79,7 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
<UserAvatar user={user} className="app-workspace-sidebar-profile-avatar" />
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
</button>
</Popover>