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>

View File

@@ -0,0 +1,21 @@
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: "" };
}

View File

@@ -1,8 +1,17 @@
import type { ImageCropRect } from "./image-operation-plan";
import { assertPixelSize, type ImageCropRect, type PixelSize } from "./image-operation-plan";
export type CropGrip = readonly [-1 | 0 | 1, -1 | 0 | 1];
const limit = (n: number, a: number, b: number) => Math.max(a, Math.min(b, n));
const minimum = 0.06;
export function centeredAspectSelection(size: PixelSize, aspect: number): ImageCropRect {
assertPixelSize(size);
if (!Number.isFinite(aspect) || aspect <= 0) throw new RangeError("裁剪比例无效");
const ratio = (aspect * size.height) / size.width;
const height = Math.min(1, 1 / ratio);
const width = height * ratio;
return { x: (1 - width) / 2, y: (1 - height) / 2, width, height };
}
export function translateSelection(box: ImageCropRect, dx: number, dy: number): ImageCropRect {
return { width: box.width, height: box.height, x: limit(box.x + dx, 0, 1 - box.width), y: limit(box.y + dy, 0, 1 - box.height) };
}
@@ -29,7 +38,8 @@ export function resizeSelection(box: ImageCropRect, grip: CropGrip, dx: number,
const proposed = 1 + (dx * vx + dy * vy) / (vx * vx + vy * vy);
const capacity = (anchor: number, fraction: number) => Math.min(fraction ? anchor / fraction : Infinity, fraction < 1 ? (1 - anchor) / (1 - fraction) : Infinity);
const ceiling = Math.min(capacity(ax, fx) / box.width, capacity(ay, fy) / box.height);
const floor = Math.min(ceiling, Math.max(minimum / box.width, minimum / box.height));
// 以较长的归一化边限制最小尺寸,避免极宽/高原图的窄轴把缩小比例锁死。
const floor = Math.min(ceiling, minimum / Math.max(box.width, box.height));
const factor = limit(proposed, floor, ceiling);
const width = box.width * factor;
const height = box.height * factor;

View File

@@ -0,0 +1,16 @@
export const DEFAULT_USER_AVATARS = [
{ id: "person", label: "默认人像" },
{ id: "director", label: "导演" },
{ id: "camera", label: "摄影师" },
{ id: "cat", label: "小猫" },
{ id: "rabbit", label: "小兔" },
{ id: "robot", label: "机器人" },
{ id: "rocket", label: "探索者" },
{ id: "sparkles", label: "灵感" },
] as const;
export type DefaultUserAvatarID = (typeof DEFAULT_USER_AVATARS)[number]["id"];
export function isDefaultUserAvatarID(value: unknown): value is DefaultUserAvatarID {
return DEFAULT_USER_AVATARS.some((avatar) => avatar.id === value);
}

View File

@@ -1,7 +1,7 @@
export type SettingsSection = "channels" | "models" | "preferences" | "prompts" | "storage";
export type SettingsSection = "profile" | "channels" | "models" | "preferences" | "prompts" | "storage";
export function settingsPath(section: SettingsSection = "channels", continueCreation = false) {
const params = new URLSearchParams({ section });
export function settingsPath(section?: SettingsSection, continueCreation = false) {
const params = new URLSearchParams({ section: section ?? (continueCreation ? "channels" : "profile") });
if (continueCreation) params.set("continue", "1");
return `/settings?${params.toString()}`;
}

View File

@@ -0,0 +1,8 @@
// Profile responses use a canonical /api path; deployment routing belongs to
// the configured API base, including its origin and reverse-proxy prefix.
export function resolveUserAvatarURL(candidate: string | undefined, apiBaseURL: string): string {
if (!candidate) return "";
if (/^https?:\/\//i.test(candidate)) return candidate;
if (!/^\/api\/auth\/profile\/avatar\/[a-zA-Z0-9_-]+\?v=[a-zA-Z0-9_-]+$/.test(candidate)) return "";
return `${apiBaseURL.replace(/\/+$/, "")}${candidate.slice("/api".length)}`;
}

View File

@@ -84,6 +84,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
label: "用户与运营",
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
{ path: "/admin/login-logs", label: "用户登录日志", description: "登录、失败与主动退出", icon: <FileClock className="size-4" /> },
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: <BellRing className="size-4" /> },
{ path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon: <Megaphone className="size-4" /> },
{ path: "/admin/agent-lessons", label: "Agent 记忆", description: "按用户查看个人记忆", icon: <Sparkles className="size-4" /> },

View File

@@ -0,0 +1,75 @@
import { Alert, Button, Skeleton } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { AppDrawer } from "@/components/ui/product/app-drawer/app-drawer";
import { getAdminLoginLog, type LoginLog } from "@/services/api/login-logs";
import { loginEventLabels, loginLogDevice, loginMethodLabels, loginSourceLabels } from "./login-log-display";
export function LoginLogDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) {
const [detail, setDetail] = useState<LoginLog | null>(null);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
setDetail(null);
setError("");
if (!id) return;
const controller = new AbortController();
void getAdminLoginLog(id, controller.signal)
.then(({ log }) => {
if (!controller.signal.aborted) setDetail(log);
})
.catch((cause) => {
if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "读取日志详情失败");
});
return () => controller.abort();
}, [id, retry]);
const device = detail ? loginLogDevice(detail.userAgent) : null;
const entries = detail
? [
["时间", dayjs(detail.createdAt).format("YYYY-MM-DD HH:mm:ss.SSS Z")],
["用户昵称", detail.displayName || "未关联用户"],
["账号", detail.username || detail.account || "未识别"],
["用户 ID", detail.userId || "未关联用户"],
["事件", loginEventLabels[detail.event]],
["来源", loginSourceLabels[detail.source]],
["登录方式", loginMethodLabels[detail.method]],
["结果", detail.result === "success" ? "成功" : "失败"],
["IP", detail.ip || "未记录"],
["浏览器", device?.browser || "未记录"],
["操作系统", device?.os || "未记录"],
["客户端自报信息", detail.userAgent || "未记录"],
["失败原因", detail.message || "无"],
["原因码", detail.reason || "无"],
["关联会话标识", detail.sessionRef || "未关联会话"],
["日志 ID", detail.id],
]
: [];
return (
<AppDrawer title="登录日志详情" open={Boolean(id)} onClose={onClose} size={560}>
{error ? (
<Alert
type="error"
showIcon
title="详情读取失败"
description={error}
action={
<Button size="small" onClick={() => setRetry((value) => value + 1)}>
重试
</Button>
}
/>
) : !detail ? (
<Skeleton active />
) : (
<dl className="space-y-5">
{entries.map(([label, value]) => (
<div key={label}>
<dt className="mb-1 text-xs text-foreground/50">{label}</dt>
<dd className="break-words text-sm text-foreground">{value}</dd>
</div>
))}
</dl>
)}
</AppDrawer>
);
}

View File

@@ -0,0 +1,28 @@
export const loginMethodLabels: Record<string, string> = { password: "密码", email: "邮箱验证码", sms: "短信验证码", linuxdo: "Linux.do", verification: "验证码(方式未识别)", unknown: "未记录" };
export const loginEventLabels: Record<string, string> = { login: "登录", logout: "主动退出" };
export const loginSourceLabels: Record<string, string> = { login: "用户登录", registration: "注册后自动登录", logout: "主动退出" };
// UA 只用于描述客户端自报信息,不能作为身份或安全判断依据。
export function loginLogDevice(userAgent: string) {
if (!userAgent) return { browser: "未记录", os: "未记录" };
const browsers: Array<[RegExp, string]> = [
[/(?:Edg|EdgA|EdgiOS)\/(\d+)/, "Edge"],
[/OPR\/(\d+)/, "Opera"],
[/(?:Firefox|FxiOS)\/(\d+)/, "Firefox"],
[/(?:Chrome|CriOS)\/(\d+)/, "Chrome"],
[/Version\/(\d+).*Safari\//, "Safari"],
];
let browser = "未识别浏览器";
for (const [pattern, name] of browsers) {
const match = userAgent.match(pattern);
if (match) {
browser = `${name} ${match[1]}`;
break;
}
}
const android = userAgent.match(/Android ([\d.]+)/);
const ios = userAgent.match(/(?:iPhone|iPad|iPod).*OS ([\d_]+)/);
const mac = userAgent.match(/Mac OS X ([\d_]+)/);
const os = android ? `Android ${android[1]}` : ios ? `iOS ${ios[1].replaceAll("_", ".")}` : /Windows NT/.test(userAgent) ? "Windows" : mac ? `macOS ${mac[1].replaceAll("_", ".")}` : /Linux/.test(userAgent) ? "Linux" : "未识别系统";
return { browser, os };
}

View File

@@ -0,0 +1,68 @@
[data-admin-root] .admin-list-toolbar:has(.admin-login-log-filters) {
container-name: admin-login-log-toolbar;
container-type: inline-size;
}
[data-admin-root] .admin-list-toolbar-main:has(> .admin-login-log-filters) {
width: 100%;
min-width: 0;
flex: 1 1 auto;
}
[data-admin-root] .admin-list-toolbar:has(.admin-login-log-filters) > .admin-list-toolbar-actions:empty {
display: none;
}
.admin-login-log-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
align-items: center;
width: 100%;
min-width: 0;
}
.admin-login-log-filters > * {
width: 100% !important;
min-width: 0 !important;
max-width: none;
}
.admin-login-log-search,
.admin-login-log-dates {
grid-column: 1 / -1;
}
.admin-login-log-filters > .admin-login-log-reset {
grid-column: 2;
justify-self: end;
width: auto !important;
}
@container admin-login-log-toolbar (min-width: 640px) {
.admin-login-log-filters {
grid-template-columns: minmax(180px, 1.7fr) minmax(100px, 1fr) minmax(128px, 1.2fr) minmax(88px, 1fr);
}
.admin-login-log-search {
grid-column: auto;
}
.admin-login-log-dates {
grid-column: span 2;
}
.admin-login-log-filters > .admin-login-log-reset {
grid-column: auto;
}
}
@container admin-login-log-toolbar (min-width: 1080px) {
.admin-login-log-filters {
grid-template-columns: minmax(180px, 1fr) 104px 140px 96px minmax(120px, 0.8fr) minmax(300px, 1.6fr) 80px;
}
.admin-login-log-dates {
grid-column: auto;
}
}

View File

@@ -0,0 +1,222 @@
import { Alert, Button, DatePicker } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import { RefreshCw, RotateCcw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { Select } from "@/components/ui/base/select";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { listAdminLoginLogs, type LoginLog } from "@/services/api/login-logs";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminSearchInput } from "../components/admin-search-input";
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, PaginationBar } from "../components/admin-ui";
import { loginEventLabels, loginLogDevice, loginMethodLabels, loginSourceLabels } from "./login-log-display";
import { LoginLogDetailDrawer } from "./login-log-detail-drawer";
import "./login-logs-page.css";
export default function LoginLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("keyword") || "";
const event = searchParams.get("event") || "";
const method = searchParams.get("method") || "";
const result = searchParams.get("result") || "";
const ip = searchParams.get("ip") || "";
const from = searchParams.get("from") || "";
const to = searchParams.get("to") || "";
const rawPage = Number(searchParams.get("page") || 1);
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
const rawSize = Number(searchParams.get("pageSize") || 20);
const pageSize = [20, 50, 100].includes(rawSize) ? rawSize : 20;
const debouncedKeyword = useDebouncedValue(keyword);
const debouncedIP = useDebouncedValue(ip);
const [logs, setLogs] = useState<LoginLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
const [detailId, setDetailId] = useState<string | null>(null);
const filtered = Boolean(keyword || event || method || result || ip || from || to);
const update = (patch: Record<string, string | number>, replace = false) => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(patch)) {
if (value === "" || (key === "page" && value === 1) || (key === "pageSize" && value === 20)) next.delete(key);
else next.set(key, String(value));
}
setSearchParams(next, { replace });
};
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
setLogs([]);
setTotal(0);
void listAdminLoginLogs(
{ keyword: debouncedKeyword || undefined, event: event || undefined, method: method || undefined, result: result || undefined, ip: debouncedIP || undefined, from: from || undefined, to: to || undefined, page, pageSize },
controller.signal,
)
.then((data) => {
if (controller.signal.aborted) return;
setLogs(data.logs);
setTotal(data.total);
if (page > 1 && data.logs.length === 0) update({ page: 1 }, true);
})
.catch((cause) => {
if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "读取登录日志失败");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [debouncedKeyword, event, method, result, debouncedIP, from, to, page, pageSize, retry]);
const columns: ColumnsType<LoginLog> = [
{
title: "时间",
width: 180,
render: (_, log) => (
<button type="button" className="admin-log-detail-link" aria-label={`查看登录日志 ${log.id} 详情`} onClick={() => setDetailId(log.id)}>
{dayjs(log.createdAt).format("YYYY-MM-DD HH:mm:ss")}
</button>
),
},
{
title: "用户 / 账号",
width: 190,
render: (_, log) => (
<div className="min-w-0">
<div className="truncate font-medium">{log.displayName || log.username || "未关联用户"}</div>
<div className="truncate text-xs text-foreground/50">{log.username ? `@${log.username}` : log.account || "账号未识别"}</div>
</div>
),
},
{
title: "事件",
width: 160,
render: (_, log) => (
<div>
<div>{loginEventLabels[log.event]}</div>
{log.source === "registration" ? <div className="text-xs text-foreground/50">{loginSourceLabels[log.source]}</div> : null}
</div>
),
},
{ title: "登录方式", width: 165, render: (_, log) => loginMethodLabels[log.method] },
{ title: "结果", width: 92, render: (_, log) => <AdminStatusBadge label={log.result === "success" ? "成功" : "失败"} tone={log.result === "success" ? "success" : "error"} /> },
{ title: "IP", dataIndex: "ip", width: 175, render: (value: string) => <span className="break-all font-mono text-xs">{value || "未记录"}</span> },
{
title: "浏览器 / 系统",
width: 190,
render: (_, log) => {
const device = loginLogDevice(log.userAgent);
return (
<div>
<div>{device.browser}</div>
<div className="text-xs text-foreground/50">{device.os}</div>
</div>
);
},
},
{
title: "失败原因",
width: 250,
render: (_, log) =>
log.result === "failed" ? (
<div className="line-clamp-2" title={log.message}>
{log.message}
</div>
) : (
<span className="text-foreground/35">—</span>
),
},
];
return (
<AdminPageFrame
title="用户登录日志"
description="登录、注册后自动登录与主动退出 · 时间按当前设备时区显示"
actions={
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => setRetry((value) => value + 1)}>
刷新
</Button>
}
>
{error ? (
<Alert
type="error"
showIcon
title="登录日志读取失败"
description={error}
action={
<Button size="small" onClick={() => setRetry((value) => value + 1)}>
重试
</Button>
}
/>
) : null}
<AdminDataTable<LoginLog>
toolbar={
<div className="admin-login-log-filters">
<AdminSearchInput
allowClear
className="admin-login-log-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索用户昵称、账号或 ID"
aria-label="搜索登录日志用户"
onValueChange={(value) => update({ keyword: value, page: 1 }, true)}
/>
<Select ariaLabel="事件类型" value={event} options={[{ value: "", label: "全部事件" }, ...Object.entries(loginEventLabels).map(([value, label]) => ({ value, label }))]} onChange={(value) => update({ event: value, page: 1 })} />
<Select
ariaLabel="登录方式"
value={method}
options={[{ value: "", label: "全部登录方式" }, ...Object.entries(loginMethodLabels).map(([value, label]) => ({ value, label }))]}
onChange={(value) => update({ method: value, page: 1 })}
/>
<Select
ariaLabel="登录结果"
value={result}
options={[
{ value: "", label: "全部结果" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
]}
onChange={(value) => update({ result: value, page: 1 })}
/>
<AdminSearchInput allowClear value={ip} placeholder="筛选 IP" aria-label="筛选登录 IP" onValueChange={(value) => update({ ip: value, page: 1 }, true)} />
<DatePicker.RangePicker
className="admin-login-log-dates"
showTime
format="YYYY-MM-DD HH:mm:ss"
aria-label="登录日志时间范围"
value={[from && dayjs(from).isValid() ? dayjs(from) : null, to && dayjs(to).isValid() ? dayjs(to) : null]}
placeholder={["开始时间", "结束时间"]}
onChange={(dates) => update({ from: dates?.[0]?.toISOString() || "", to: dates?.[1]?.toISOString() || "", page: 1 })}
/>
<Button
type="text"
className="admin-login-log-reset"
disabled={!filtered}
title={filtered ? "清除全部筛选条件" : "当前未应用筛选"}
icon={<RotateCcw className="size-3.5" />}
onClick={() => update({ keyword: "", event: "", method: "", result: "", ip: "", from: "", to: "", page: 1 })}
>
重置
</Button>
</div>
}
empty={
error ? (
<AdminTableEmpty title="日志暂时无法读取" description="请重试,当前未展示查询结果。" />
) : (
<AdminTableEmpty filtered={filtered} title={filtered ? "没有匹配的登录日志" : "暂无登录日志"} description={filtered ? "调整筛选条件后重试。" : "开始记录后的登录与主动退出事件将显示在这里。"} />
)
}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(next, size) => update({ page: next, pageSize: size })} />}
table={{ rowKey: "id", dataSource: logs, columns, loading, pagination: false, scroll: { x: 1400 }, size: "small" }}
/>
<LoginLogDetailDrawer id={detailId} onClose={() => setDetailId(null)} />
</AdminPageFrame>
);
}

View File

@@ -7,6 +7,7 @@ import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/g
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingDots } from "@/components/ai/working-indicator";
import { UserAvatar } from "@/components/layout/user-avatar";
import { generationErrorMessage } from "@/lib/generation-error";
import { useEffect, useState } from "react";
import { useCopyText } from "@/hooks/use-copy-text";
@@ -126,14 +127,19 @@ export function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { i
const copyText = useCopyText();
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
const user = useUserStore((state) => state.user);
const userAvatarUrl = user?.avatarUrl?.trim();
return (
<article className="creation-user-message">
<div className="creation-user-message-meta">
{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}
{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}
<strong>{user?.displayName || "你"}</strong>
<span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span>
{user ? (
<UserAvatar user={user} className="creation-user-avatar" />
) : (
<span className="creation-user-avatar">
<UserRound />
</span>
)}
</div>
<div className="creation-user-message-copy-wrap">
<p>{visiblePrompt}</p>

View File

@@ -1,6 +1,6 @@
import { App, Button, InputNumber } from "antd";
import { SettingsRow } from "@/components/ui/product/settings-row";
import { ArrowLeft, Boxes, Brain, Bug, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, Workflow } from "lucide-react";
import { ArrowLeft, Boxes, Brain, Bug, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, UserRound, Workflow } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
@@ -16,10 +16,12 @@ import AgentMemoryPane from "./agent-memory-pane";
import { RunningHubSettingsPane } from "./runninghub-settings-pane";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { usePluginStore } from "@/stores/use-plugin-store";
import { ProfilePane } from "./profile-pane";
type ConfigSectionKey = "channels" | "models" | "runninghub" | "preferences" | "prompts" | "agent-memory" | "storage" | "diagnostics";
type ConfigSectionKey = "profile" | "channels" | "models" | "runninghub" | "preferences" | "prompts" | "agent-memory" | "storage" | "diagnostics";
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
{ key: "profile", label: "个人信息", description: "头像、密码与邮箱", icon: <UserRound className="size-4" /> },
{ key: "channels", label: "个人渠道", description: "模型服务与个人工作流", icon: <RadioTower className="size-4" /> },
{ key: "runninghub", label: "RunningHub 工作流", description: "个人渠道的云端工作流配置", icon: <Workflow className="size-4" /> },
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
@@ -43,7 +45,7 @@ export default function SettingsPage() {
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
const runningHubPluginEnabled = runtimeStatuses[RUNNINGHUB_PLUGIN_ID] === "enabled";
const requestedSectionEnabled = requestedSection !== "runninghub" || runningHubPluginEnabled;
const initialSection = isConfigSection(requestedSection) && requestedSectionEnabled ? requestedSection : customChannelsEnabled ? "channels" : "models";
const initialSection = isConfigSection(requestedSection) && requestedSectionEnabled ? requestedSection : "profile";
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(initialSection === "channels" && !customChannelsEnabled ? "models" : initialSection);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
@@ -61,7 +63,7 @@ export default function SettingsPage() {
setActiveTab(requestedSection);
return;
}
setActiveTab((current) => visibleConfigSections.some((section) => section.key === current) ? current : customChannelsEnabled ? "channels" : "models");
setActiveTab(requestedSection === "channels" && !customChannelsEnabled ? "models" : "profile");
}, [customChannelsEnabled, requestedSection, visibleConfigSections]);
useEffect(() => {
@@ -102,6 +104,7 @@ export default function SettingsPage() {
};
const panes: Record<ConfigSectionKey, ReactNode> = {
profile: <SettingsPane><ProfilePane key={userId} /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} /></SettingsPane>,
models: (
<SettingsPane>

View File

@@ -0,0 +1,315 @@
.profile-pane {
container-type: inline-size;
}
.profile-sections {
display: flex;
flex-direction: column;
gap: var(--space-6);
padding-bottom: var(--space-6);
}
.profile-overview {
padding: var(--space-5);
border: 1px solid var(--user-border);
border-radius: var(--r-lg);
background: var(--user-surface-muted);
}
.profile-overview-heading h3,
.profile-security-heading h3 {
margin: 0;
color: var(--user-ink);
font-size: var(--fs-body-lg);
font-weight: 650;
}
.profile-overview-heading p,
.profile-security-heading p {
margin: var(--space-1) 0 0;
color: var(--user-ink-muted);
font-size: var(--fs-caption);
line-height: 1.6;
}
.profile-overview-main {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-5);
}
.profile-avatar-preview {
width: 88px;
height: 88px;
flex-shrink: 0;
border-radius: var(--r-lg);
object-fit: cover;
object-position: center;
background: var(--user-surface);
color: var(--user-ink-muted);
}
.profile-overview-identity {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: var(--space-1);
}
.profile-overview-identity strong {
overflow: hidden;
color: var(--user-ink);
font-size: var(--fs-title);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-overview-identity span {
overflow: hidden;
color: var(--user-ink-muted);
font-size: var(--fs-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-security-heading {
margin-bottom: calc(-1 * var(--space-2));
}
.profile-security-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: var(--space-4);
}
.profile-security-card {
display: flex;
min-width: 0;
flex-direction: column;
padding: var(--space-5);
border: 1px solid var(--user-border);
border-radius: var(--r-lg);
background: var(--user-surface);
}
.profile-card-heading {
display: flex;
align-items: flex-start;
gap: var(--space-3);
min-height: 76px;
margin-bottom: var(--space-4);
}
.profile-card-icon {
display: inline-flex;
width: 36px;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--user-radius-control);
background: var(--user-accent-soft);
color: var(--user-accent);
}
.profile-card-heading h4 {
margin: 0;
color: var(--user-ink);
font-size: var(--fs-body);
font-weight: 650;
}
.profile-card-heading p {
margin: var(--space-1) 0 0;
color: var(--user-ink-muted);
font-size: var(--fs-caption);
line-height: 1.6;
}
.profile-current-email {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-2);
margin-bottom: var(--space-4);
padding: var(--space-3);
border-radius: var(--user-radius-control);
background: var(--user-surface-muted);
font-size: var(--fs-caption);
}
.profile-current-email > span:first-child {
width: 100%;
color: var(--user-ink-muted);
}
.profile-current-email strong {
min-width: 0;
color: var(--user-ink);
font-weight: 560;
overflow-wrap: anywhere;
}
.profile-email-status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--user-ink-muted);
white-space: nowrap;
}
.profile-email-status.is-verified {
color: var(--status-success);
}
.profile-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.profile-avatar-dialog-body {
min-height: 250px;
padding-top: var(--space-2);
}
.profile-avatar-upload {
display: flex;
align-items: center;
gap: var(--space-5);
min-height: 204px;
padding: var(--space-5) 0;
}
.profile-avatar-dialog-preview {
width: 104px;
height: 104px;
flex-shrink: 0;
border-radius: var(--user-radius-control);
object-fit: cover;
object-position: center;
background: var(--user-surface-muted);
color: var(--user-ink-muted);
}
.profile-avatar-upload-controls {
min-width: 0;
}
.profile-avatar-upload-title {
color: var(--user-ink);
font-size: var(--fs-body);
font-weight: 600;
}
.profile-hint {
margin-block: var(--space-3);
color: var(--user-ink-muted);
font-size: var(--fs-caption);
line-height: 1.7;
}
.profile-form {
display: flex;
flex: 1;
flex-direction: column;
}
.profile-form > .ant-btn {
align-self: flex-start;
margin-top: auto;
}
.profile-feedback {
margin-block: var(--space-3);
}
.profile-code-row {
display: flex;
align-items: end;
gap: var(--space-3);
}
.profile-code-row > .ant-form-item {
flex: 1;
min-width: 0;
}
.profile-code-row > .ant-btn {
margin-bottom: var(--space-6);
}
.profile-reauth-error {
color: var(--status-error);
margin-top: var(--space-2);
}
.profile-default-avatar-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin-block: var(--space-4);
}
.profile-default-avatar-choice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--user-border);
border-radius: var(--user-radius-control);
color: var(--user-ink-muted);
background: var(--user-surface);
font-size: var(--fs-caption);
transition:
background-color var(--motion-dur-fast) var(--motion-ease-out),
border-color var(--motion-dur-fast) var(--motion-ease-out);
}
.profile-default-avatar-choice:hover {
background: var(--user-surface-hover);
}
.profile-default-avatar-choice[aria-pressed="true"] {
border-color: var(--user-accent);
background: var(--user-accent-soft);
color: var(--user-accent);
}
.profile-default-avatar-choice:focus-visible {
outline: 2px solid var(--user-accent);
outline-offset: 2px;
}
.profile-default-avatar-choice:disabled {
cursor: wait;
opacity: 0.65;
}
.profile-default-avatar-icon.ant-avatar {
color: var(--user-accent);
background: var(--user-accent-soft);
}
@media (prefers-reduced-motion: reduce) {
.profile-default-avatar-choice {
transition: none;
}
}
@container (max-width: 740px) {
.profile-security-grid {
grid-template-columns: 1fr;
}
.profile-card-heading {
min-height: 0;
}
}
@container (max-width: 470px) {
.profile-overview {
padding: var(--space-4);
}
.profile-overview-main {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: var(--space-2) var(--space-3);
}
.profile-overview-main > .ant-btn {
grid-column: 2;
justify-self: start;
}
.profile-avatar-preview {
width: 64px;
height: 64px;
}
.profile-security-card {
padding: var(--space-4);
}
}
@media (max-width: 639px) {
.profile-avatar-upload {
align-items: flex-start;
gap: var(--space-3);
}
.profile-avatar-dialog-preview {
width: 72px;
height: 72px;
}
.profile-actions > .ant-btn {
min-height: 40px;
}
.profile-pane [data-slot="callout"] {
flex-wrap: wrap;
}
.profile-form .ant-btn {
min-height: 40px;
}
.profile-default-avatar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,605 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Avatar, Button, Form, Input, Skeleton } from "antd";
import { BadgeCheck, Crop, ImagePlus, KeyRound, Mail, RefreshCw, ShieldCheck } from "lucide-react";
import { useEffect, useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { UserAvatar } from "@/components/layout/user-avatar";
import { ImageCropDialog, type ImageCropRect } from "@/components/image-crop-dialog";
import { AppModal } from "@/components/ui/product/app-modal";
import { Callout } from "@/components/ui/product/callout";
import { SegmentedControl } from "@/components/ui/base/segmented-control";
import { DEFAULT_USER_AVATARS } from "@/lib/default-user-avatars";
import { cropProfileAvatar } from "@/services/profile-avatar";
import { loadProfileSession } from "@/services/profile-session";
import { applyUserSession } from "@/lib/user-session";
import { invalidateAuthSessionCache, type LocalUser } from "@/services/api/auth";
import { changeProfileEmail, changeProfilePassword, profileLinuxDOReauthURL, requestProfileEmailCode, selectDefaultProfileAvatar, uploadProfileAvatar, type UserProfile } from "@/services/api/profile";
import { ApiError } from "@/services/api/request";
import { useUserStore } from "@/stores/use-user-store";
import { profileAvatarError, profilePasswordError, profileReauthReady, profileSecondsRemaining } from "./profile-validation";
import "./profile-pane.css";
type PasswordFields = { currentPassword: string; newPassword: string; confirmPassword: string };
type EmailFields = { currentPassword: string; email: string; emailCode: string };
type PendingAction = "crop-avatar" | "avatar" | "reset-avatar" | "password" | "email-code" | "email";
type AvatarTab = "upload" | "preset";
type EmailTicket = { value: string; expiresAt: number };
const errorMessage = (error: unknown, fallback: string) => (error instanceof Error ? error.message : fallback);
export function ProfilePane() {
const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const userId = useUserStore((state) => state.user?.id);
const queryKey = ["user-profile", userId];
const profileQuery = useQuery({ queryKey, queryFn: ({ signal }) => loadProfileSession(userId, signal), enabled: !!userId, retry: false, refetchOnWindowFocus: false, staleTime: 0 });
const profile = profileQuery.data;
const [passwordForm] = Form.useForm<PasswordFields>();
const [emailForm] = Form.useForm<EmailFields>();
const [pending, setPending] = useState<PendingAction | null>(null);
const pendingRef = useRef(false);
const [errors, setErrors] = useState({ avatar: "", password: "", email: "" });
const [draftAvatar, setDraftAvatar] = useState<{ file: File; url: string; sourceFile: File } | null>(null);
const [cropSource, setCropSource] = useState<{ file: File; url: string } | null>(null);
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const [avatarTab, setAvatarTab] = useState<AvatarTab>("upload");
const [defaultAvatarDraft, setDefaultAvatarDraft] = useState("person");
const cropGeneration = useRef(0);
const fileInput = useRef<HTMLInputElement>(null);
const [emailTicket, setEmailTicket] = useState<EmailTicket | null>(null);
const [retryAt, setRetryAt] = useState(0);
const [now, setNow] = useState(Date.now());
const oauthResult = searchParams.get("reauth");
const [reauthResult] = useState(oauthResult);
const reauthReady = profileReauthReady(profile?.reauthenticatedUntil, now);
const identityReady = !!profile?.hasPassword || reauthReady;
const retrySeconds = profileSecondsRemaining(retryAt, now);
const ticketSeconds = emailTicket ? profileSecondsRemaining(emailTicket.expiresAt, now) : 0;
useEffect(() => {
if (!oauthResult) return;
const next = new URLSearchParams(searchParams);
next.delete("reauth");
setSearchParams(next, { replace: true });
}, [oauthResult, searchParams, setSearchParams]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(
() => () => {
if (draftAvatar) URL.revokeObjectURL(draftAvatar.url);
},
[draftAvatar],
);
useEffect(
() => () => {
if (cropSource) URL.revokeObjectURL(cropSource.url);
},
[cropSource],
);
useEffect(
() => () => {
cropGeneration.current += 1;
},
[],
);
const setError = (section: keyof typeof errors, value: string) => setErrors((current) => ({ ...current, [section]: value }));
const begin = (action: PendingAction) => {
if (pendingRef.current) return false;
pendingRef.current = true;
setPending(action);
return true;
};
const end = () => {
pendingRef.current = false;
setPending(null);
};
const updateAvatarUser = (user: LocalUser) => {
if (useUserStore.getState().user?.id !== userId || user.id !== userId) return;
invalidateAuthSessionCache();
useUserStore.getState().setUser(user);
queryClient.setQueryData<UserProfile>(queryKey, (current) => (current ? { ...current, user } : current));
setDraftAvatar(null);
setCropSource(null);
setAvatarDialogOpen(false);
};
const openAvatarDialog = () => {
if (!profile) return;
setAvatarTab(profile.user.avatarMode === "default" ? "preset" : "upload");
setDefaultAvatarDraft(profile.user.avatarPreset || "person");
setError("avatar", "");
setAvatarDialogOpen(true);
};
const closeAvatarDialog = () => {
if (pendingRef.current) return;
cropGeneration.current += 1;
setCropSource(null);
setDraftAvatar(null);
setAvatarDialogOpen(false);
setError("avatar", "");
};
const selectAvatar = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const error = profileAvatarError(file);
setError("avatar", error || "");
if (error) return;
setCropSource({ file, url: URL.createObjectURL(file) });
};
const confirmAvatarCrop = async (crop: ImageCropRect) => {
if (!cropSource || !begin("crop-avatar")) return;
const source = cropSource;
const generation = cropGeneration.current;
setError("avatar", "");
try {
const file = await cropProfileAvatar(source.url, crop, source.file.name);
if (cropGeneration.current !== generation || useUserStore.getState().user?.id !== userId) return;
setDraftAvatar({ file, url: URL.createObjectURL(file), sourceFile: source.file });
setCropSource(null);
} catch (error) {
if (cropGeneration.current !== generation) return;
const msg = errorMessage(error, "裁剪头像失败,请重试");
setError("avatar", msg);
message.error(msg);
} finally {
end();
}
};
const saveAvatar = async () => {
if (!draftAvatar || !begin("avatar")) return;
setError("avatar", "");
try {
// 取消仍在加载的旧资料,避免写入成功后被旧头像响应覆盖。
await queryClient.cancelQueries({ queryKey });
const { user } = await uploadProfileAvatar(draftAvatar.file);
await queryClient.cancelQueries({ queryKey });
updateAvatarUser(user);
message.success("头像已保存");
} catch (error) {
setError("avatar", errorMessage(error, "保存头像失败,请重试"));
} finally {
end();
}
};
const saveDefaultAvatar = async () => {
if (!begin("reset-avatar")) return;
setError("avatar", "");
try {
await queryClient.cancelQueries({ queryKey });
const { user } = await selectDefaultProfileAvatar(defaultAvatarDraft);
await queryClient.cancelQueries({ queryKey });
updateAvatarUser(user);
message.success("已使用选择的系统头像");
} catch (error) {
setError("avatar", errorMessage(error, "保存头像失败,请重试"));
} finally {
end();
}
};
const finishSecurityChange = async (notice: string, notificationFailed = false) => {
if (useUserStore.getState().user?.id !== userId) return;
invalidateAuthSessionCache();
passwordForm.resetFields();
emailForm.resetFields();
setEmailTicket(null);
try {
await applyUserSession({ user: null });
if (notificationFailed) message.warning({ content: notice, duration: 8 });
else message.success({ content: notice, duration: 6 });
} catch (error) {
useUserStore.getState().clearSession();
useUserStore.getState().setHydrated(true);
message.error({ content: `${notice} 清理本地会话失败:${errorMessage(error, "请重新登录")}`, duration: 8 });
} finally {
navigate("/login", { replace: true });
}
};
const savePassword = async (values: PasswordFields) => {
if (!(profile?.hasPassword || profileReauthReady(profile?.reauthenticatedUntil, Date.now())) || !begin("password")) return;
setError("password", "");
try {
await changeProfilePassword({ currentPassword: profile?.hasPassword ? values.currentPassword : "", newPassword: values.newPassword });
await finishSecurityChange("密码已更新,所有登录会话已退出,请重新登录");
} catch (error) {
setError("password", errorMessage(error, "修改密码失败,请重试"));
} finally {
end();
}
};
const sendEmailCode = async () => {
if (!profile?.emailEnabled || !(profile.hasPassword || profileReauthReady(profile.reauthenticatedUntil, Date.now())) || profileSecondsRemaining(retryAt, Date.now()) || pendingRef.current) return;
let values: EmailFields;
try {
values = await emailForm.validateFields(profile.hasPassword ? ["email", "currentPassword"] : ["email"]);
} catch {
return;
}
if (!begin("email-code")) return;
setError("email", "");
setEmailTicket(null);
emailForm.setFieldValue("emailCode", "");
try {
const result = await requestProfileEmailCode({ email: values.email.trim(), currentPassword: profile.hasPassword ? values.currentPassword : "" });
const receivedAt = Date.now();
setEmailTicket({ value: result.ticket, expiresAt: receivedAt + result.expiresIn * 1000 });
setRetryAt(receivedAt + result.retryAfter * 1000);
setNow(receivedAt);
message.success("验证码已发送到新邮箱");
} catch (error) {
if (error instanceof ApiError && error.retryAfterMs) setRetryAt(Date.now() + error.retryAfterMs);
setError("email", errorMessage(error, "发送验证码失败,请重试"));
} finally {
end();
}
};
const saveEmail = async (values: EmailFields) => {
if (!profile?.emailEnabled || !(profile.hasPassword || profileReauthReady(profile.reauthenticatedUntil, Date.now())) || !emailTicket || !profileSecondsRemaining(emailTicket.expiresAt, Date.now()) || !begin("email")) return;
setError("email", "");
try {
const result = await changeProfileEmail({ ticket: emailTicket.value, emailCode: values.emailCode, currentPassword: profile.hasPassword ? values.currentPassword : "" });
await finishSecurityChange(result.notificationStatus === "failed" ? "邮箱已更新,但旧邮箱通知发送失败。所有登录会话已退出,请重新登录" : "邮箱已更新,所有登录会话已退出,请重新登录", result.notificationStatus === "failed");
} catch (error) {
setError("email", errorMessage(error, "修改邮箱失败,请重试"));
} finally {
end();
}
};
const resetEmailTicket = (changed: Partial<EmailFields>) => {
setError("email", "");
if ("email" in changed || "currentPassword" in changed) {
setEmailTicket(null);
emailForm.setFieldValue("emailCode", "");
}
};
return (
<div className="profile-pane">
<div className="settings-pane-header">
<div className="min-w-0">
<h2>个人信息</h2>
<p>管理你的头像、登录密码与邮箱。</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={profileQuery.isFetching} disabled={!!pending} onClick={() => void profileQuery.refetch()}>
刷新资料
</Button>
</div>
{profileQuery.isPending ? (
<Skeleton active paragraph={{ rows: 7 }} />
) : profileQuery.isError ? (
<Callout
tone="error"
title="个人信息加载失败"
role="alert"
action={
<Button size="small" onClick={() => void profileQuery.refetch()}>
重试
</Button>
}
>
{errorMessage(profileQuery.error, "请稍后重试")}
</Callout>
) : profile ? (
<div className="profile-sections">
<section className="profile-overview" aria-labelledby="profile-avatar-title">
<div className="profile-overview-heading">
<h3 id="profile-avatar-title">头像与账号</h3>
<p>头像会同步显示在工作区和创作消息中。</p>
</div>
<div className="profile-overview-main">
<UserAvatar user={profile.user} className="profile-avatar-preview" />
<div className="profile-overview-identity">
<strong>{profile.user.displayName || profile.user.username}</strong>
<span>账号 · {profile.user.username}</span>
</div>
<Button type="primary" icon={<ImagePlus className="size-4" />} disabled={!!pending} onClick={openAvatarDialog}>
更换头像
</Button>
</div>
</section>
{!profile.hasPassword ? (
<Callout
tone={reauthReady ? "success" : "info"}
title="验证当前身份"
icon={ShieldCheck}
action={
profile.linuxdoEnabled ? (
<Button disabled={!!pending} onClick={() => window.location.assign(profileLinuxDOReauthURL())}>
{reauthReady ? "重新验证" : "使用 Linux.do 验证"}
</Button>
) : undefined
}
>
{reauthReady
? `身份验证已通过,有效期剩余 ${profileSecondsRemaining(Date.parse(profile.reauthenticatedUntil!), now)} 秒。`
: profile.linuxdoEnabled
? "设置密码或更换邮箱前,请使用已绑定的 Linux.do 账号重新验证。验证有效期为 5 分钟。"
: "当前账号未设置密码,且 Linux.do 验证未启用,请联系管理员。"}
{reauthResult === "error" && !reauthReady ? (
<p className="profile-reauth-error" role="alert">
Linux.do 身份验证失败,请重新验证。
</p>
) : null}
</Callout>
) : null}
<div className="profile-security-heading">
<h3>账户安全</h3>
<p>密码与邮箱分别验证,修改成功后需要重新登录。</p>
</div>
<div className="profile-security-grid">
<section className="profile-security-card" aria-labelledby="profile-password-title">
<div className="profile-card-heading">
<span className="profile-card-icon">
<KeyRound className="size-5" />
</span>
<div>
<h4 id="profile-password-title">{profile.hasPassword ? "修改密码" : "设置登录密码"}</h4>
<p>更新后,所有设备上的登录会话都会退出。</p>
</div>
</div>
<Form
form={passwordForm}
name="profile-password"
layout="vertical"
requiredMark={false}
className="profile-form"
disabled={!!pending || !identityReady}
onFinish={(values) => void savePassword(values)}
onValuesChange={() => setError("password", "")}
>
{profile.hasPassword ? (
<Form.Item name="currentPassword" label="当前密码" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
) : null}
<Form.Item
name="newPassword"
label="新密码"
rules={[
{
validator: async (_, value: string = "") => {
const error = profilePasswordError(value);
if (error) throw new Error(error);
},
},
]}
>
<Input.Password autoComplete="new-password" placeholder="至少 8 个字符" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={["newPassword"]}
rules={[
{ required: true, message: "请再次输入新密码" },
{
validator: async (_, value) => {
if (value && value !== passwordForm.getFieldValue("newPassword")) throw new Error("两次输入的密码不一致");
},
},
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
{errors.password ? (
<Callout tone="error" role="alert" className="profile-feedback">
{errors.password}
</Callout>
) : null}
<Button type="primary" htmlType="submit" loading={pending === "password"} disabled={!!pending || !identityReady}>
{profile.hasPassword ? "更新密码" : "设置密码"}
</Button>
</Form>
</section>
<section className="profile-security-card" aria-labelledby="profile-email-title">
<div className="profile-card-heading">
<span className="profile-card-icon">
<Mail className="size-5" />
</span>
<div>
<h4 id="profile-email-title">更换邮箱</h4>
<p>验证新邮箱后生效,并退出所有登录会话。</p>
</div>
</div>
<div className="profile-current-email">
<span>当前邮箱</span>
<strong>{profile.user.email || "尚未绑定"}</strong>
{profile.user.email ? (
<span className={`profile-email-status${profile.user.emailVerifiedAt ? " is-verified" : ""}`}>
{profile.user.emailVerifiedAt ? <BadgeCheck className="size-3.5" /> : null}
{profile.user.emailVerifiedAt ? "已验证" : "未验证"}
</span>
) : null}
</div>
{!profile.emailEnabled ? (
<Callout tone="warning" title="邮件服务未启用">
管理员尚未配置可用的邮件服务,暂时无法更换邮箱。
</Callout>
) : (
<Form
form={emailForm}
name="profile-email"
layout="vertical"
requiredMark={false}
className="profile-form"
disabled={!!pending || !identityReady}
onValuesChange={resetEmailTicket}
onFinish={(values) => void saveEmail(values)}
>
{profile.hasPassword ? (
<Form.Item name="currentPassword" label="当前密码" rules={[{ required: true, message: "请输入当前密码以验证身份" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
) : null}
<Form.Item
name="email"
label="新邮箱"
rules={[
{ required: true, whitespace: true, message: "请输入新邮箱" },
{ type: "email", transform: (value: string) => value?.trim(), message: "请输入有效的邮箱地址" },
{
validator: async (_, value: string = "") => {
if (value.trim().toLowerCase() === profile.user.email?.toLowerCase()) throw new Error("新邮箱不能与当前邮箱相同");
},
},
]}
>
<Input type="email" autoComplete="email" />
</Form.Item>
<div className="profile-code-row">
<Form.Item
name="emailCode"
label="邮箱验证码"
rules={[
{ required: true, message: "请输入邮箱验证码" },
{ pattern: /^\d{6}$/, message: "请输入 6 位数字验证码" },
]}
>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} />
</Form.Item>
<Button loading={pending === "email-code"} disabled={!!pending || !identityReady || retrySeconds > 0} onClick={() => void sendEmailCode()}>
{retrySeconds ? `${retrySeconds} 秒后重发` : emailTicket ? "重新发送" : "发送验证码"}
</Button>
</div>
{emailTicket ? (
<p className="profile-hint" role="status">
{ticketSeconds ? `验证码已发送,${Math.ceil(ticketSeconds / 60)} 分钟内有效。` : "验证码已过期,请重新发送。"}
</p>
) : null}
{errors.email ? (
<Callout tone="error" role="alert" className="profile-feedback">
{errors.email}
</Callout>
) : null}
<Button type="primary" htmlType="submit" loading={pending === "email"} disabled={!!pending || !identityReady || !ticketSeconds}>
更换邮箱
</Button>
</Form>
)}
</section>
</div>
</div>
) : null}
{profile ? (
<AppModal
open={avatarDialogOpen}
centered
width={560}
title="更换头像"
okText="保存头像"
confirmLoading={pending === "avatar" || pending === "reset-avatar"}
okButtonProps={{ disabled: !!pending || (avatarTab === "upload" ? !draftAvatar : profile.user.avatarMode === "default" && (profile.user.avatarPreset || "person") === defaultAvatarDraft) }}
cancelButtonProps={{ disabled: !!pending }}
closable={!pending}
keyboard={!pending}
mask={{ closable: !pending }}
onCancel={closeAvatarDialog}
onOk={() => void (avatarTab === "upload" ? saveAvatar() : saveDefaultAvatar())}
>
<div className="profile-avatar-dialog-body">
<SegmentedControl<AvatarTab>
block
ariaLabel="头像来源"
value={avatarTab}
disabled={!!pending}
options={[
{ value: "upload", label: "上传图片" },
{ value: "preset", label: "系统头像" },
]}
onChange={(tab) => {
setAvatarTab(tab);
setError("avatar", "");
}}
/>
{avatarTab === "upload" ? (
<div className="profile-avatar-upload">
{draftAvatar ? (
<img
className="profile-avatar-dialog-preview"
src={draftAvatar.url}
alt="待保存的头像预览"
onError={() => {
setDraftAvatar(null);
setError("avatar", "无法读取图片,请选择有效的 PNG 或 JPEG 图片");
}}
/>
) : (
<UserAvatar user={profile.user} className="profile-avatar-dialog-preview" />
)}
<div className="profile-avatar-upload-controls">
<p className="profile-avatar-upload-title">{draftAvatar ? "裁剪后的头像" : "当前头像"}</p>
<p className="profile-hint">选择 PNG 或 JPEG 图片,可裁剪为正方形;文件最大 5 MiB。</p>
<input ref={fileInput} type="file" accept="image/png,image/jpeg" hidden aria-label="选择头像图片" onChange={selectAvatar} disabled={!!pending} />
<div className="profile-actions">
<Button icon={<ImagePlus className="size-4" />} disabled={!!pending} onClick={() => fileInput.current?.click()}>
{draftAvatar ? "重新选择" : "选择图片"}
</Button>
{draftAvatar ? (
<Button icon={<Crop className="size-4" />} disabled={!!pending} onClick={() => setCropSource({ file: draftAvatar.sourceFile, url: URL.createObjectURL(draftAvatar.sourceFile) })}>
调整裁剪
</Button>
) : null}
</div>
</div>
</div>
) : (
<>
<p className="profile-hint">选择一款系统头像,保存后会同步到工作区。</p>
<div className="profile-default-avatar-grid" role="group" aria-label="系统头像">
{DEFAULT_USER_AVATARS.map((preset) => (
<button key={preset.id} type="button" className="profile-default-avatar-choice" aria-pressed={defaultAvatarDraft === preset.id} disabled={!!pending} onClick={() => setDefaultAvatarDraft(preset.id)}>
<Avatar size={56} className="profile-default-avatar-icon" icon={<UserAvatar user={{ ...profile.user, avatarUrl: undefined, avatarMode: "default", avatarPreset: preset.id }} className="size-8" />} />
<span>{preset.label}</span>
</button>
))}
</div>
</>
)}
{errors.avatar ? (
<Callout tone="error" role="alert" className="profile-feedback">
{errors.avatar}
</Callout>
) : null}
</div>
</AppModal>
) : null}
{cropSource ? (
<ImageCropDialog
dataUrl={cropSource.url}
open
title="裁剪头像"
fixedAspect={1}
confirmLoading={pending === "crop-avatar"}
onClose={() => {
if (!pendingRef.current) setCropSource(null);
}}
onConfirm={(crop) => void confirmAvatarCrop(crop)}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
export function profileAvatarError(file: Pick<File, "type" | "size">): string | undefined {
if (file.type !== "image/png" && file.type !== "image/jpeg") return "请选择 PNG 或 JPEG 图片";
if (!file.size) return "图片文件不能为空";
if (file.size > PROFILE_AVATAR_MAX_BYTES) return "图片大小不能超过 5 MB";
}
export function profilePasswordError(password: string): string | undefined {
if ([...password].length < 8) return "密码至少需要 8 个字符";
if (new TextEncoder().encode(password).length > 72) return "密码不能超过 72 字节,请缩短密码";
}
export function profileSecondsRemaining(until: number, now: number): number {
return Math.max(0, Math.ceil((until - now) / 1000));
}
export function profileReauthReady(until: string | undefined, now: number): boolean {
return !!until && Date.parse(until) > now;
}

View File

@@ -25,6 +25,7 @@ const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page")
const SkillCurationPage = lazy(() => import("@/pages/admin/skill-curation-page"));
const AdminPaymentsPage = lazy(() => import("@/pages/admin/payments/payments-page"));
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
const LoginLogsPage = lazy(() => import("@/pages/admin/login-logs/login-logs-page"));
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page"));
const AppearanceSettingsPage = lazy(() => import("@/pages/admin/settings/appearance-settings-page"));
@@ -189,6 +190,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <AnalyticsPage /> },
{ path: "users", element: <UsersPage /> },
{ path: "login-logs", element: <LoginLogsPage /> },
{ path: "channels", element: <ChannelsPage /> },
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled"><LogicalModelsPage /></RequireFeature> },
{ path: "plugins", element: <AdminPluginsPage /> },

View File

@@ -23,6 +23,8 @@ export type LocalUser = {
phoneVerifiedAt?: string;
displayName: string;
avatarUrl?: string;
avatarMode?: "custom" | "default" | "";
avatarPreset?: string;
identityProvider?: string;
identityId?: string;
identityUsername?: string;

View File

@@ -0,0 +1,39 @@
import { compactApiParams, http } from "./request";
export type LoginLog = {
id: string;
userId: string | null;
username: string;
displayName: string;
account: string;
event: "login" | "logout";
method: "password" | "email" | "sms" | "linuxdo" | "verification" | "unknown";
source: "login" | "registration" | "logout";
result: "success" | "failed";
ip: string;
userAgent: string;
sessionRef: string;
reason: string;
message: string;
createdAt: string;
};
export type LoginLogParams = {
keyword?: string;
event?: string;
method?: string;
result?: string;
ip?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
};
export function listAdminLoginLogs(params: LoginLogParams, signal?: AbortSignal) {
return http.get<{ logs: LoginLog[]; total: number; page: number; pageSize: number }>("/admin/login-logs", { params: compactApiParams(params), signal });
}
export function getAdminLoginLog(id: string, signal?: AbortSignal) {
return http.get<{ log: LoginLog }>(`/admin/login-logs/${encodeURIComponent(id)}`, { signal });
}

View File

@@ -0,0 +1,29 @@
import type { LocalUser } from "./auth";
import { apiBaseURL, http } from "./request";
export type UserProfile = {
user: LocalUser;
hasPassword: boolean;
emailEnabled: boolean;
linuxdoEnabled: boolean;
reauthenticatedUntil?: string;
};
export type ProfileEmailTicket = { ticket: string; expiresIn: number; retryAfter: number };
export type ProfileEmailResult = { changed: true; notificationStatus: "sent" | "failed" | "not_needed" };
export const getUserProfile = (signal?: AbortSignal) => http.get<UserProfile>("/auth/profile", { signal });
export function uploadProfileAvatar(file: File) {
const data = new FormData();
data.append("file", file);
return http.put<{ user: LocalUser }>("/auth/profile/avatar", data);
}
export const resetProfileAvatar = () => http.delete<{ user: LocalUser }>("/auth/profile/avatar");
export const selectDefaultProfileAvatar = (preset: string) => http.post<{ user: LocalUser }>("/auth/profile/avatar/default", { preset });
export const changeProfilePassword = (input: { currentPassword: string; newPassword: string }) => http.post<{ changed: true }>("/auth/profile/password", input);
export const requestProfileEmailCode = (input: { email: string; currentPassword: string }) => http.post<ProfileEmailTicket>("/auth/profile/email-code", input);
export const changeProfileEmail = (input: { ticket: string; emailCode: string; currentPassword: string }) => http.post<ProfileEmailResult>("/auth/profile/email", input);
export const profileLinuxDOReauthURL = () => `${apiBaseURL.replace(/\/$/, "")}/auth/profile/linuxdo/start`;

View File

@@ -0,0 +1,42 @@
import { assertPixelSize, resolveCropPixels, type ImageCropRect, type PixelSize } from "@/lib/canvas/image-operation-plan";
import { loadRasterImage } from "@/lib/image-raster";
const MAX_AVATAR_PIXELS = 16_000_000;
const MAX_AVATAR_SIDE = 512;
export function planProfileAvatarCrop(size: PixelSize, crop: ImageCropRect): { source: ImageCropRect; outputSide: number } {
assertPixelSize(size);
if (size.width * size.height > MAX_AVATAR_PIXELS) throw new RangeError("图片像素不能超过 1600 万,请先缩小图片");
const source = resolveCropPixels(size, crop);
if (crop.x < -1e-12 || crop.y < -1e-12 || crop.x + crop.width > 1 + 1e-12 || crop.y + crop.height > 1 + 1e-12) throw new RangeError("裁剪区域超出图片边界");
if (Math.abs(crop.width * size.width - crop.height * size.height) > 1e-6) throw new RangeError("头像裁剪区域必须为正方形");
// 浮点选区向像素取整可能多出一列或一行,保留选区中心并输出严格方形。
const side = Math.min(source.width, source.height);
return { source: { x: source.x + Math.floor((source.width - side) / 2), y: source.y + Math.floor((source.height - side) / 2), width: side, height: side }, outputSide: Math.min(side, MAX_AVATAR_SIDE) };
}
export async function cropProfileAvatar(sourceURL: string, crop: ImageCropRect, _originalName: string): Promise<File> {
const image = await loadRasterImage(sourceURL);
let canvas: HTMLCanvasElement | undefined;
try {
const { source, outputSide } = planProfileAvatarCrop({ width: image.naturalWidth, height: image.naturalHeight }, crop);
canvas = document.createElement("canvas");
canvas.width = outputSide;
canvas.height = outputSide;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法创建头像裁剪画布");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, outputSide, outputSide);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas!.toBlob((result) => (result && result.size > 0 ? resolve(result) : reject(new Error("头像 PNG 导出失败,请重试"))), "image/png");
});
return new File([blob], "avatar.png", { type: "image/png" });
} finally {
image.src = "";
if (canvas) {
canvas.width = 0;
canvas.height = 0;
}
}
}

View File

@@ -0,0 +1,12 @@
import { invalidateAuthSessionCache } from "@/services/api/auth";
import { getUserProfile } from "@/services/api/profile";
import { useUserStore } from "@/stores/use-user-store";
export async function loadProfileSession(userId: string | undefined, signal: AbortSignal) {
const profile = await getUserProfile(signal);
if (!signal.aborted && useUserStore.getState().user?.id === userId && profile.user.id === userId) {
invalidateAuthSessionCache();
useUserStore.getState().setUser(profile.user);
}
return profile;
}

View File

@@ -6,8 +6,13 @@ export type LocalUser = {
id: string;
username: string;
email?: string;
phone?: string;
emailVerifiedAt?: string;
phoneVerifiedAt?: string;
displayName: string;
avatarUrl?: string;
avatarMode?: "custom" | "default" | "";
avatarPreset?: string;
identityProvider?: string;
identityId?: string;
identityUsername?: string;

View File

@@ -86,10 +86,10 @@ test("channel model fetch requires explicit selection before import", async () =
expect(component).toContain("setFetchPreviewOpen(true)");
expect(component).toContain("setSelectedFetchModels(result.models)");
expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型");
expect(component).toContain("disabled={importing || allFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}");
expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}");
expect(component).toContain("onClick={() => setSelectedFetchModels([])}");
expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}");
expect(component).toContain("disabled={importing || selectedVisibleFetchCount === 0}");
expect(component).toContain("onClick={() => setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}");
expect(component).toContain("取消全选");
expect(component).toContain("disabled={importing}");
expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)");

View File

@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import { runInNewContext } from "node:vm";
import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position";
@@ -36,7 +37,7 @@ test("small pointer jitter remains a click, but dragging back to the origin rema
// Execute the production hook with a tiny hook scheduler; only React and browser
// side effects are substituted, not the pointer/click handlers being tested.
const build = await Bun.build({
entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname],
entrypoints: [fileURLToPath(new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url))],
target: "browser",
format: "cjs",
plugins: [

View File

@@ -1,12 +1,13 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { isIsolatedPrevisRepro } from "../src/lib/dev-repro";
// Execute the real entry point; replace only its font, network and UI side effects.
async function prepareEntry(dev, pathname) {
const build = await Bun.build({
entrypoints: [new URL("../src/main.tsx", import.meta.url).pathname],
entrypoints: [fileURLToPath(new URL("../src/main.tsx", import.meta.url))],
target: "browser",
format: "iife",
define: { "import.meta.env.DEV": JSON.stringify(dev) },

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import { loginLogDevice } from "../src/pages/admin/login-logs/login-log-display";
describe("登录日志设备识别", () => {
test("Edge 不误判为 Chrome,Android 不误判为 Linux", () => {
expect(loginLogDevice("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0")).toEqual({ browser: "Edge 130", os: "Windows" });
expect(loginLogDevice("Mozilla/5.0 (Linux; Android 14) Chrome/130.0.0.0 Mobile Safari/537.36")).toEqual({ browser: "Chrome 130", os: "Android 14" });
});
test("iPhone 和未知 UA 明确展示识别边界", () => {
expect(loginLogDevice("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Version/18.0 Mobile/15E148 Safari/604.1")).toEqual({ browser: "Safari 18", os: "iOS 18.0" });
expect(loginLogDevice("custom-client")).toEqual({ browser: "未识别浏览器", os: "未识别系统" });
expect(loginLogDevice("")).toEqual({ browser: "未记录", os: "未记录" });
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import { centeredAspectSelection, resizeSelection, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection";
import { cropProfileAvatar, planProfileAvatarCrop } from "../src/services/profile-avatar";
describe("fixed avatar crop geometry", () => {
test("starts with the largest centered square on wide, tall and odd images", () => {
for (const size of [
{ width: 1200, height: 800 },
{ width: 800, height: 1200 },
{ width: 901, height: 600 },
]) {
const crop = centeredAspectSelection(size, 1);
expect(crop.width * size.width).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.height * size.height).toBeCloseTo(Math.min(size.width, size.height));
expect(crop.x + crop.width / 2).toBeCloseTo(0.5);
expect(crop.y + crop.height / 2).toBeCloseTo(0.5);
const plan = planProfileAvatarCrop(size, crop);
expect(plan.source.width).toBe(plan.source.height);
expect(plan.outputSide).toBe(512);
}
});
test("every locked grip and movement preserves physical square pixels", () => {
const size = { width: 1200, height: 600 };
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const resized = resizeSelection(initial, [x, y] as CropGrip, -0.1, -0.1, true);
const moved = translateSelection(resized, 0.2, -0.3);
expect(moved.width * size.width).toBeCloseTo(moved.height * size.height);
expect(planProfileAvatarCrop(size, moved).source.width).toBe(planProfileAvatarCrop(size, moved).source.height);
}
});
test("square crops on panoramic images can shrink from every grip and remain square", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
for (const x of [-1, 0, 1] as const)
for (const y of [-1, 0, 1] as const) {
if (!x && !y) continue;
const next = resizeSelection(initial, [x, y], (-x * initial.width) / 4, (-y * initial.height) / 4, true);
expect(next.width * size.width).toBeCloseTo(75);
expect(next.height * size.height).toBeCloseTo(75);
expect(next.x).toBeGreaterThanOrEqual(0);
expect(next.y).toBeGreaterThanOrEqual(0);
expect(next.x + next.width).toBeLessThanOrEqual(1);
expect(next.y + next.height).toBeLessThanOrEqual(1);
}
}
});
test("one-pixel keyboard steps shrink panoramic squares and enforce a reachable positive minimum", () => {
for (const size of [
{ width: 2000, height: 100 },
{ width: 100, height: 2000 },
]) {
const initial = centeredAspectSelection(size, 1);
expect(resizeSelection(initial, [1, 0], -1 / size.width, 0, true).width * size.width).toBeCloseTo(99);
expect(resizeSelection(initial, [0, 1], 0, -1 / size.height, true).height * size.height).toBeCloseTo(99);
const smallest = resizeSelection(initial, [1, 1], -10, -10, true);
expect(smallest.width * size.width).toBeCloseTo(6);
expect(smallest.height * size.height).toBeCloseTo(6);
}
});
test("rejects decoded images over 16M pixels, invalid and non-square crops", () => {
expect(() => planProfileAvatarCrop({ width: 4001, height: 4000 }, { x: 0, y: 0, width: 1, height: 1 })).toThrow("1600");
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0, y: 0, width: 1, height: 0.5 })).toThrow("正方形");
expect(() => centeredAspectSelection({ width: 100, height: 0 }, 1)).toThrow();
expect(() => centeredAspectSelection({ width: 100, height: 100 }, 0)).toThrow();
expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 2, y: 0, width: 1, height: 1 })).toThrow();
expect(planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0.2, y: 0.3, width: 0.4, height: 0.4 })).toEqual({ source: { x: 20, y: 30, width: 40, height: 40 }, outputSide: 40 });
});
});
async function withRasterDOM(width: number, height: number, run: (state: { drawn: unknown[][]; imageSources: string[]; canvas: { width: number; height: number }; failExport: () => void }) => Promise<void>) {
const previousImage = globalThis.Image;
const previousDocument = globalThis.document;
const drawn: unknown[][] = [];
const imageSources: string[] = [];
let exportFails = false;
class TestImage {
naturalWidth = width;
naturalHeight = height;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
imageSources.push(value);
if (value) queueMicrotask(() => this.onload?.());
}
}
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...values: unknown[]) => drawn.push(values) }), toBlob: (callback: (blob: Blob | null) => void) => callback(exportFails ? null : new Blob(["png"], { type: "image/png" })) };
Object.assign(globalThis, { Image: TestImage, document: { createElement: () => canvas } });
try {
await run({
drawn,
imageSources,
canvas,
failExport: () => {
exportFails = true;
},
});
} finally {
Object.assign(globalThis, { Image: previousImage, document: previousDocument });
}
}
describe("avatar PNG export", () => {
test("draws the chosen source region into a square bounded by 512 and releases buffers", async () => {
await withRasterDOM(1200, 800, async ({ drawn, canvas, imageSources }) => {
const file = await cropProfileAvatar("blob:test", { x: 0.25, y: 0.125, width: 0.5, height: 0.75 }, "../../private.jpg");
expect(drawn[0].slice(1)).toEqual([300, 100, 600, 600, 0, 0, 512, 512]);
expect(file.name).toBe("avatar.png");
expect(file.type).toBe("image/png");
expect(file.size).toBeGreaterThan(0);
expect(canvas.width).toBe(0);
expect(canvas.height).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("fails explicitly when PNG export fails and still releases image and canvas", async () => {
await withRasterDOM(100, 100, async ({ failExport, canvas, imageSources }) => {
failExport();
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("PNG");
expect(canvas.width).toBe(0);
expect(imageSources.at(-1)).toBe("");
});
});
test("rejects pixel bombs before drawing", async () => {
await withRasterDOM(5000, 5000, async ({ drawn, imageSources }) => {
await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("1600");
expect(drawn).toHaveLength(0);
expect(imageSources.at(-1)).toBe("");
});
});
});

View File

@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { QueryClient } from "@tanstack/react-query";
import { apiClient } from "../src/services/api/request";
import { loadProfileSession } from "../src/services/profile-session";
import { useUserStore, type LocalUser } from "../src/stores/use-user-store";
const originalUser: LocalUser = { id: "profile-user", username: "user", displayName: "User", role: "user", status: "active", avatarMode: "default", avatarPreset: "person" };
test("profile refresh synchronizes avatars while rejecting stale account and cancelled responses", async () => {
const originalRequest = apiClient.request;
const previousUser = useUserStore.getState().user;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
try {
for (const scenario of ["refresh", "switched-account", "wrong-user", "cancelled", "failure"] as const) {
useUserStore.getState().setUser(originalUser);
let respond!: (value: unknown) => void;
let reject!: (reason: Error) => void;
apiClient.request = (() =>
new Promise((resolve, fail) => {
respond = resolve;
reject = fail;
})) as typeof apiClient.request;
const key = ["user-profile", scenario];
const request = client.fetchQuery({ queryKey: key, queryFn: ({ signal }) => loadProfileSession(originalUser.id, signal) }).catch((error) => error);
if (scenario === "switched-account") useUserStore.getState().setUser({ ...originalUser, id: "other-user" });
if (scenario === "cancelled") {
await client.cancelQueries({ queryKey: key });
useUserStore.getState().setUser({ ...originalUser, avatarPreset: "cat" });
}
if (scenario === "failure") reject(new Error("profile unavailable"));
else respond({ data: { code: 0, data: { user: { ...originalUser, id: scenario === "wrong-user" ? "unexpected-user" : originalUser.id, avatarPreset: "robot" } }, msg: "ok" }, status: 200 });
await request;
await Promise.resolve();
const stored = useUserStore.getState().user;
expect(stored?.id).toBe(scenario === "switched-account" ? "other-user" : originalUser.id);
expect(stored?.avatarPreset).toBe(scenario === "refresh" ? "robot" : scenario === "cancelled" ? "cat" : "person");
}
} finally {
await client.cancelQueries();
client.clear();
apiClient.request = originalRequest;
useUserStore.setState({ user: previousUser });
}
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test";
import { profileAvatarError, PROFILE_AVATAR_MAX_BYTES, profilePasswordError, profileReauthReady, profileSecondsRemaining } from "../src/pages/settings/profile-validation";
import { settingsPath } from "../src/lib/settings-navigation";
import { changeProfileEmail, changeProfilePassword, getUserProfile, profileLinuxDOReauthURL, requestProfileEmailCode, resetProfileAvatar, selectDefaultProfileAvatar, uploadProfileAvatar } from "../src/services/api/profile";
import { ApiError, apiClient } from "../src/services/api/request";
describe("profile input and security expiry", () => {
test("rejects unsupported, empty and oversized avatar uploads", () => {
expect(profileAvatarError({ type: "image/svg+xml", size: 100 })).toBeDefined();
expect(profileAvatarError({ type: "image/png", size: 0 })).toBeDefined();
expect(profileAvatarError({ type: "image/jpeg", size: PROFILE_AVATAR_MAX_BYTES + 1 })).toBeDefined();
expect(profileAvatarError({ type: "image/png", size: PROFILE_AVATAR_MAX_BYTES })).toBeUndefined();
});
test("checks password UTF-8 byte limit to prevent bcrypt truncation", () => {
expect(profilePasswordError("1234567")).toBeDefined();
expect(profilePasswordError("a".repeat(72))).toBeUndefined();
expect(profilePasswordError("a".repeat(73))).toBeDefined();
expect(profilePasswordError("密".repeat(24))).toBeUndefined();
expect(profilePasswordError("密".repeat(25))).toBeDefined();
expect(profilePasswordError("😀".repeat(4))).toBeDefined();
expect(profilePasswordError("😀".repeat(8))).toBeUndefined();
});
test("expires identity proof and code cooldown at the boundary", () => {
const deadline = "2026-10-12T12:00:00Z";
const timestamp = Date.parse(deadline);
expect(profileReauthReady(deadline, timestamp - 1)).toBe(true);
expect(profileReauthReady(deadline, timestamp)).toBe(false);
expect(profileReauthReady("invalid", timestamp)).toBe(false);
expect(profileReauthReady(undefined, timestamp)).toBe(false);
expect(profileSecondsRemaining(timestamp, timestamp - 1001)).toBe(2);
expect(profileSecondsRemaining(timestamp, timestamp + 1)).toBe(0);
});
test("account settings default to profile and creation continues to channels", () => {
expect(settingsPath()).toBe("/settings?section=profile");
expect(settingsPath(undefined, true)).toBe("/settings?section=channels&continue=1");
expect(settingsPath("models", true)).toBe("/settings?section=models&continue=1");
expect(settingsPath("profile")).toBe("/settings?section=profile");
});
});
describe("profile API request contract", () => {
test("forwards load cancellation and uses multipart avatar without forcing content type", async () => {
const original = apiClient.request;
const requests: Array<Record<string, unknown>> = [];
apiClient.request = (async (config: Record<string, unknown>) => {
requests.push(config);
return { data: { code: 0, data: { user: { id: "user-1" } }, msg: "ok" }, status: 200 };
}) as typeof apiClient.request;
try {
const signal = new AbortController().signal;
await getUserProfile(signal);
const file = new File(["image-bytes"], "avatar.png", { type: "image/png" });
await uploadProfileAvatar(file);
await resetProfileAvatar();
await selectDefaultProfileAvatar("cat");
expect(requests[0]).toMatchObject({ url: "/auth/profile", method: "get", signal });
expect(requests[1]).toMatchObject({ url: "/auth/profile/avatar", method: "put" });
const multipart = requests[1].data as FormData;
expect(multipart.get("file")).toBeInstanceOf(File);
expect((multipart.get("file") as File).name).toBe("avatar.png");
expect(requests[1].headers).toBeUndefined();
expect(requests[2]).toMatchObject({ url: "/auth/profile/avatar", method: "delete" });
expect(requests[3]).toMatchObject({ url: "/auth/profile/avatar/default", method: "post", data: { preset: "cat" } });
} finally {
apiClient.request = original;
}
});
test("keeps passwords, email codes and tickets in POST bodies", async () => {
const original = apiClient.request;
const requests: Array<Record<string, unknown>> = [];
apiClient.request = (async (config: Record<string, unknown>) => {
requests.push(config);
return { data: { code: 0, data: { changed: true }, msg: "ok" }, status: 200 };
}) as typeof apiClient.request;
try {
await changeProfilePassword({ currentPassword: "old-password", newPassword: "new-password" });
await requestProfileEmailCode({ email: "new@example.test", currentPassword: "old-password" });
await changeProfileEmail({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
expect(requests.map((request) => request.url)).toEqual(["/auth/profile/password", "/auth/profile/email-code", "/auth/profile/email"]);
expect(requests.every((request) => request.method === "post" && request.params === undefined)).toBe(true);
expect(requests[2].data).toEqual({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" });
expect(profileLinuxDOReauthURL()).toEndWith("/auth/profile/linuxdo/start");
expect(profileLinuxDOReauthURL()).not.toContain("?");
} finally {
apiClient.request = original;
}
});
test("propagates failed security writes with the backend reason", async () => {
const original = apiClient.request;
apiClient.request = (async () => ({ data: { code: 40001, data: null, msg: "身份验证失败", reason: "bad_auth_request" }, status: 400 })) as typeof apiClient.request;
try {
const error = await changeProfilePassword({ currentPassword: "wrong", newPassword: "new-password" }).catch((failure) => failure);
expect(error).toBeInstanceOf(ApiError);
expect(error.reason).toBe("bad_auth_request");
} finally {
apiClient.request = original;
}
});
});

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { UserAvatar } from "../src/components/layout/user-avatar";
import { resolveUserAvatarURL } from "../src/lib/user-avatar-url";
import { DEFAULT_USER_AVATARS, isDefaultUserAvatarID } from "../src/lib/default-user-avatars";
import { apiBaseURL } from "../src/services/api/request";
import type { LocalUser } from "../src/stores/use-user-store";
const user: LocalUser = { id: "user-1", username: "user", displayName: "User", role: "user", status: "active" };
function renderAvatar(avatarUrl?: string) {
return renderToStaticMarkup(<UserAvatar user={{ ...user, avatarUrl }} className="size-8" />);
}
describe("shared user avatar image sources", () => {
test("renders authenticated profile images and linked HTTP avatars", () => {
for (const source of ["/api/auth/profile/avatar/user-1?v=012abc", "https://example.com/avatar.png", "http://example.com/avatar.jpg"]) {
const markup = renderAvatar(source);
expect(markup).toContain(`<img src="${resolveUserAvatarURL(source, apiBaseURL)}"`);
expect(markup).toContain("object-cover");
}
});
test("rejects unrelated relative URLs, traversal, protocol-relative and executable sources", () => {
for (const source of [
undefined,
"",
"/assets/avatar.png",
"/api/auth/profile/avatar/../settings",
"/api/auth/profile/avatar/user-1/../../settings",
"/api/auth/profile/avatar/user-1?redirect=https://example.com",
"//example.com/avatar.png",
"data:image/svg+xml,test",
"javascript:alert(1)",
"blob:https://example.com/test",
]) {
const markup = renderAvatar(source);
expect(markup).not.toContain("<img");
expect(markup).toContain("<svg");
}
});
test("preserves sidebar icon styling when using the system default", () => {
const markup = renderToStaticMarkup(<UserAvatar user={user} className="size-7" iconClassName="app-workspace-account-icon" />);
expect(markup).toContain("app-workspace-account-icon");
expect(markup).not.toContain("<img");
});
});
describe("profile avatar deployment routing", () => {
const canonical = "/api/auth/profile/avatar/user-1?v=012abc";
test("resolves the canonical path onto API origins and proxy prefixes", () => {
for (const [base, expected] of [
["/api", canonical],
["/api/", canonical],
["/proxy/yingce/api", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["/proxy/yingce/api///", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/api", "https://backend.example.com/api/auth/profile/avatar/user-1?v=012abc"],
["https://backend.example.com/proxy/yingce/", "https://backend.example.com/proxy/yingce/auth/profile/avatar/user-1?v=012abc"],
["http://localhost:8080", "http://localhost:8080/auth/profile/avatar/user-1?v=012abc"],
["/", "/auth/profile/avatar/user-1?v=012abc"],
]) {
expect(resolveUserAvatarURL(canonical, base)).toBe(expected);
}
});
test("keeps third-party URLs independent of the configured API base", () => {
for (const source of ["https://thirdparty.example.com/avatar.png?q=123", "http://thirdparty.example.com/avatar.jpg"]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe(source);
}
});
test("rejects noncanonical paths before joining with a custom backend", () => {
for (const source of [
undefined,
"",
"/api/auth/profile/avatar/user-1",
"/api/auth/profile/avatar/user-1?v=",
"/api/auth/profile/avatar/user-1?v=012abc&next=/settings",
"/api/auth/profile/avatar/%2e%2e?v=012abc",
"/api/auth/profile/avatar/user-1/../other?v=012abc",
"/api/auth/profile/avatar/user-1?v=012abc#fragment",
"/proxy/api/auth/profile/avatar/user-1?v=012abc",
"//thirdparty.example.com/avatar.png",
"data:image/png;base64,AAAA",
]) {
expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe("");
}
});
});
describe("system avatar catalog and shared rendering", () => {
test("exposes eight stable distinct presets and validates exact IDs", () => {
expect(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).toEqual(["person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles"]);
expect(new Set(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).size).toBe(8);
for (const avatar of DEFAULT_USER_AVATARS) {
expect(isDefaultUserAvatarID(avatar.id)).toBe(true);
expect(avatar.label).not.toBe("");
}
for (const invalid of [undefined, null, "", "Cat", " cat", "cat ", "default", "../person"]) {
expect(isDefaultUserAvatarID(invalid)).toBe(false);
}
});
test("renders the selected icon globally and overrides inherited image URLs", () => {
const iconClasses = ["lucide-circle-user-round", "lucide-clapperboard", "lucide-camera", "lucide-cat", "lucide-rabbit", "lucide-bot", "lucide-rocket", "lucide-sparkles"];
for (const [index, preset] of DEFAULT_USER_AVATARS.entries()) {
const markup = renderToStaticMarkup(
<UserAvatar user={{ ...user, avatarMode: "default", avatarPreset: preset.id, avatarUrl: "https://example.com/inherited.png" }} className="creation-user-avatar" iconClassName="size-6 app-workspace-account-icon" />,
);
expect(markup).not.toContain("<img");
expect(markup).toContain(iconClasses[index]);
expect(markup).toContain("creation-user-avatar");
expect(markup).toContain("app-workspace-account-icon");
}
});
test("keeps custom images after remembering a preset preference", () => {
const markup = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "custom", avatarPreset: "cat", avatarUrl: "/api/auth/profile/avatar/user-1?v=012abc" }} />);
expect(markup).toContain("<img");
expect(markup).not.toContain("lucide-cat");
const legacyDefault = renderToStaticMarkup(<UserAvatar user={{ ...user, avatarMode: "default" }} />);
expect(legacyDefault).toContain("lucide-circle-user-round");
});
});