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

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

View File

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

View File

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

View File

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

View File

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