feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
190
web/src/components/image-crop-dialog.tsx
Normal file
190
web/src/components/image-crop-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
web/src/hooks/use-image-tool-source.ts
Normal file
21
web/src/hooks/use-image-tool-source.ts
Normal 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: "" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
16
web/src/lib/default-user-avatars.ts
Normal file
16
web/src/lib/default-user-avatars.ts
Normal 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);
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
8
web/src/lib/user-avatar-url.ts
Normal file
8
web/src/lib/user-avatar-url.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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" /> },
|
||||
|
||||
75
web/src/pages/admin/login-logs/login-log-detail-drawer.tsx
Normal file
75
web/src/pages/admin/login-logs/login-log-detail-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
web/src/pages/admin/login-logs/login-log-display.ts
Normal file
28
web/src/pages/admin/login-logs/login-log-display.ts
Normal 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 };
|
||||
}
|
||||
68
web/src/pages/admin/login-logs/login-logs-page.css
Normal file
68
web/src/pages/admin/login-logs/login-logs-page.css
Normal 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;
|
||||
}
|
||||
}
|
||||
222
web/src/pages/admin/login-logs/login-logs-page.tsx
Normal file
222
web/src/pages/admin/login-logs/login-logs-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
315
web/src/pages/settings/profile-pane.css
Normal file
315
web/src/pages/settings/profile-pane.css
Normal 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));
|
||||
}
|
||||
}
|
||||
605
web/src/pages/settings/profile-pane.tsx
Normal file
605
web/src/pages/settings/profile-pane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/pages/settings/profile-validation.ts
Normal file
20
web/src/pages/settings/profile-validation.ts
Normal 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;
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -23,6 +23,8 @@ export type LocalUser = {
|
||||
phoneVerifiedAt?: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
avatarMode?: "custom" | "default" | "";
|
||||
avatarPreset?: string;
|
||||
identityProvider?: string;
|
||||
identityId?: string;
|
||||
identityUsername?: string;
|
||||
|
||||
39
web/src/services/api/login-logs.ts
Normal file
39
web/src/services/api/login-logs.ts
Normal 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 });
|
||||
}
|
||||
29
web/src/services/api/profile.ts
Normal file
29
web/src/services/api/profile.ts
Normal 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`;
|
||||
42
web/src/services/profile-avatar.ts
Normal file
42
web/src/services/profile-avatar.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
web/src/services/profile-session.ts
Normal file
12
web/src/services/profile-session.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) },
|
||||
|
||||
14
web/test/login-log-device.test.ts
Normal file
14
web/test/login-log-device.test.ts
Normal 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: "未记录" });
|
||||
});
|
||||
});
|
||||
136
web/test/profile-avatar-crop.test.ts
Normal file
136
web/test/profile-avatar-crop.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
44
web/test/profile-session.test.ts
Normal file
44
web/test/profile-session.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
99
web/test/profile-settings.test.ts
Normal file
99
web/test/profile-settings.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
127
web/test/user-avatar.test.tsx
Normal file
127
web/test/user-avatar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user