diff --git a/web/src/hooks/use-image-tool-source.ts b/web/src/hooks/use-image-tool-source.ts
new file mode 100644
index 000000000..5013d2b99
--- /dev/null
+++ b/web/src/hooks/use-image-tool-source.ts
@@ -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: "" };
+}
diff --git a/web/src/lib/canvas/image-crop-selection.ts b/web/src/lib/canvas/image-crop-selection.ts
index ae3ec6b23..8d48e5fd0 100644
--- a/web/src/lib/canvas/image-crop-selection.ts
+++ b/web/src/lib/canvas/image-crop-selection.ts
@@ -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;
diff --git a/web/src/lib/default-user-avatars.ts b/web/src/lib/default-user-avatars.ts
new file mode 100644
index 000000000..6d2209432
--- /dev/null
+++ b/web/src/lib/default-user-avatars.ts
@@ -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);
+}
diff --git a/web/src/lib/settings-navigation.ts b/web/src/lib/settings-navigation.ts
index a07b9e862..d4d10cea7 100644
--- a/web/src/lib/settings-navigation.ts
+++ b/web/src/lib/settings-navigation.ts
@@ -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()}`;
}
diff --git a/web/src/lib/user-avatar-url.ts b/web/src/lib/user-avatar-url.ts
new file mode 100644
index 000000000..0684ea364
--- /dev/null
+++ b/web/src/lib/user-avatar-url.ts
@@ -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)}`;
+}
diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx
index 997fba222..4a85b2211 100644
--- a/web/src/pages/admin/components/admin-shell.tsx
+++ b/web/src/pages/admin/components/admin-shell.tsx
@@ -84,6 +84,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
label: "用户与运营",
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon:
},
+ { path: "/admin/login-logs", label: "用户登录日志", description: "登录、失败与主动退出", icon:
},
{ path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon:
},
{ path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon:
},
{ path: "/admin/agent-lessons", label: "Agent 记忆", description: "按用户查看个人记忆", icon:
},
diff --git a/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx b/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx
new file mode 100644
index 000000000..4a9c90201
--- /dev/null
+++ b/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx
@@ -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
(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 (
+
+ {error ? (
+ setRetry((value) => value + 1)}>
+ 重试
+
+ }
+ />
+ ) : !detail ? (
+
+ ) : (
+
+ {entries.map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/admin/login-logs/login-log-display.ts b/web/src/pages/admin/login-logs/login-log-display.ts
new file mode 100644
index 000000000..7e03603d2
--- /dev/null
+++ b/web/src/pages/admin/login-logs/login-log-display.ts
@@ -0,0 +1,28 @@
+export const loginMethodLabels: Record = { password: "密码", email: "邮箱验证码", sms: "短信验证码", linuxdo: "Linux.do", verification: "验证码(方式未识别)", unknown: "未记录" };
+export const loginEventLabels: Record = { login: "登录", logout: "主动退出" };
+export const loginSourceLabels: Record = { 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 };
+}
diff --git a/web/src/pages/admin/login-logs/login-logs-page.css b/web/src/pages/admin/login-logs/login-logs-page.css
new file mode 100644
index 000000000..c219fad64
--- /dev/null
+++ b/web/src/pages/admin/login-logs/login-logs-page.css
@@ -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;
+ }
+}
diff --git a/web/src/pages/admin/login-logs/login-logs-page.tsx b/web/src/pages/admin/login-logs/login-logs-page.tsx
new file mode 100644
index 000000000..821c7d650
--- /dev/null
+++ b/web/src/pages/admin/login-logs/login-logs-page.tsx
@@ -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([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [retry, setRetry] = useState(0);
+ const [detailId, setDetailId] = useState(null);
+ const filtered = Boolean(keyword || event || method || result || ip || from || to);
+
+ const update = (patch: Record, 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 = [
+ {
+ title: "时间",
+ width: 180,
+ render: (_, log) => (
+
+ ),
+ },
+ {
+ title: "用户 / 账号",
+ width: 190,
+ render: (_, log) => (
+
+
{log.displayName || log.username || "未关联用户"}
+
{log.username ? `@${log.username}` : log.account || "账号未识别"}
+
+ ),
+ },
+ {
+ title: "事件",
+ width: 160,
+ render: (_, log) => (
+
+
{loginEventLabels[log.event]}
+ {log.source === "registration" ?
{loginSourceLabels[log.source]}
: null}
+
+ ),
+ },
+ { title: "登录方式", width: 165, render: (_, log) => loginMethodLabels[log.method] },
+ { title: "结果", width: 92, render: (_, log) => },
+ { title: "IP", dataIndex: "ip", width: 175, render: (value: string) => {value || "未记录"} },
+ {
+ title: "浏览器 / 系统",
+ width: 190,
+ render: (_, log) => {
+ const device = loginLogDevice(log.userAgent);
+ return (
+
+
{device.browser}
+
{device.os}
+
+ );
+ },
+ },
+ {
+ title: "失败原因",
+ width: 250,
+ render: (_, log) =>
+ log.result === "failed" ? (
+
+ {log.message}
+
+ ) : (
+ —
+ ),
+ },
+ ];
+
+ return (
+ } loading={loading} onClick={() => setRetry((value) => value + 1)}>
+ 刷新
+
+ }
+ >
+ {error ? (
+ setRetry((value) => value + 1)}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+ toolbar={
+
+
}
+ value={keyword}
+ placeholder="搜索用户昵称、账号或 ID"
+ aria-label="搜索登录日志用户"
+ onValueChange={(value) => update({ keyword: value, page: 1 }, true)}
+ />
+
+ }
+ empty={
+ error ? (
+
+ ) : (
+
+ )
+ }
+ footer={ update({ page: next, pageSize: size })} />}
+ table={{ rowKey: "id", dataSource: logs, columns, loading, pagination: false, scroll: { x: 1400 }, size: "small" }}
+ />
+ setDetailId(null)} />
+
+ );
+}
diff --git a/web/src/pages/create/creation-workspace-messages.tsx b/web/src/pages/create/creation-workspace-messages.tsx
index d3981d626..19c5174ab 100644
--- a/web/src/pages/create/creation-workspace-messages.tsx
+++ b/web/src/pages/create/creation-workspace-messages.tsx
@@ -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 (
{shotNumber > 0 ?
镜 {shotNumber} : null}
{item.createdAt ?
: null}
{user?.displayName || "你"}
-
{userAvatarUrl ?
: }
+ {user ? (
+
+ ) : (
+
+
+
+ )}
{visiblePrompt}
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 1c2798b1b..64e179c0b 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -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:
},
{ key: "channels", label: "个人渠道", description: "模型服务与个人工作流", icon:
},
{ key: "runninghub", label: "RunningHub 工作流", description: "个人渠道的云端工作流配置", icon:
},
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon:
},
@@ -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
(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 = {
+ profile: ,
channels: selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} />,
models: (
diff --git a/web/src/pages/settings/profile-pane.css b/web/src/pages/settings/profile-pane.css
new file mode 100644
index 000000000..5a0296d6d
--- /dev/null
+++ b/web/src/pages/settings/profile-pane.css
@@ -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));
+ }
+}
diff --git a/web/src/pages/settings/profile-pane.tsx b/web/src/pages/settings/profile-pane.tsx
new file mode 100644
index 000000000..0e5d4351d
--- /dev/null
+++ b/web/src/pages/settings/profile-pane.tsx
@@ -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();
+ const [emailForm] = Form.useForm();
+ const [pending, setPending] = useState(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("upload");
+ const [defaultAvatarDraft, setDefaultAvatarDraft] = useState("person");
+ const cropGeneration = useRef(0);
+ const fileInput = useRef(null);
+ const [emailTicket, setEmailTicket] = useState(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(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) => {
+ 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) => {
+ setError("email", "");
+ if ("email" in changed || "currentPassword" in changed) {
+ setEmailTicket(null);
+ emailForm.setFieldValue("emailCode", "");
+ }
+ };
+
+ return (
+
+
+
+
个人信息
+
管理你的头像、登录密码与邮箱。
+
+
} loading={profileQuery.isFetching} disabled={!!pending} onClick={() => void profileQuery.refetch()}>
+ 刷新资料
+
+
+ {profileQuery.isPending ? (
+
+ ) : profileQuery.isError ? (
+
void profileQuery.refetch()}>
+ 重试
+
+ }
+ >
+ {errorMessage(profileQuery.error, "请稍后重试")}
+
+ ) : profile ? (
+
+
+
+
头像与账号
+
头像会同步显示在工作区和创作消息中。
+
+
+
+
+ {profile.user.displayName || profile.user.username}
+ 账号 · {profile.user.username}
+
+
} disabled={!!pending} onClick={openAvatarDialog}>
+ 更换头像
+
+
+
+
+ {!profile.hasPassword ? (
+
window.location.assign(profileLinuxDOReauthURL())}>
+ {reauthReady ? "重新验证" : "使用 Linux.do 验证"}
+
+ ) : undefined
+ }
+ >
+ {reauthReady
+ ? `身份验证已通过,有效期剩余 ${profileSecondsRemaining(Date.parse(profile.reauthenticatedUntil!), now)} 秒。`
+ : profile.linuxdoEnabled
+ ? "设置密码或更换邮箱前,请使用已绑定的 Linux.do 账号重新验证。验证有效期为 5 分钟。"
+ : "当前账号未设置密码,且 Linux.do 验证未启用,请联系管理员。"}
+ {reauthResult === "error" && !reauthReady ? (
+
+ Linux.do 身份验证失败,请重新验证。
+
+ ) : null}
+
+ ) : null}
+
+
+
账户安全
+
密码与邮箱分别验证,修改成功后需要重新登录。
+
+
+
+
+
+
+
+
+
{profile.hasPassword ? "修改密码" : "设置登录密码"}
+
更新后,所有设备上的登录会话都会退出。
+
+
+
+
+
+ ) : null}
+ {
+ const error = profilePasswordError(value);
+ if (error) throw new Error(error);
+ },
+ },
+ ]}
+ >
+
+
+ {
+ if (value && value !== passwordForm.getFieldValue("newPassword")) throw new Error("两次输入的密码不一致");
+ },
+ },
+ ]}
+ >
+
+
+ {errors.password ? (
+
+ {errors.password}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
更换邮箱
+
验证新邮箱后生效,并退出所有登录会话。
+
+
+
+ 当前邮箱
+ {profile.user.email || "尚未绑定"}
+ {profile.user.email ? (
+
+ {profile.user.emailVerifiedAt ? : null}
+ {profile.user.emailVerifiedAt ? "已验证" : "未验证"}
+
+ ) : null}
+
+ {!profile.emailEnabled ? (
+
+ 管理员尚未配置可用的邮件服务,暂时无法更换邮箱。
+
+ ) : (
+
+
+
+ ) : null}
+ value?.trim(), message: "请输入有效的邮箱地址" },
+ {
+ validator: async (_, value: string = "") => {
+ if (value.trim().toLowerCase() === profile.user.email?.toLowerCase()) throw new Error("新邮箱不能与当前邮箱相同");
+ },
+ },
+ ]}
+ >
+
+
+
+
+
+
+
+
+ {emailTicket ? (
+
+ {ticketSeconds ? `验证码已发送,${Math.ceil(ticketSeconds / 60)} 分钟内有效。` : "验证码已过期,请重新发送。"}
+
+ ) : null}
+ {errors.email ? (
+
+ {errors.email}
+
+ ) : null}
+
+
+ )}
+
+
+
+ ) : null}
+ {profile ? (
+
void (avatarTab === "upload" ? saveAvatar() : saveDefaultAvatar())}
+ >
+
+
+ block
+ ariaLabel="头像来源"
+ value={avatarTab}
+ disabled={!!pending}
+ options={[
+ { value: "upload", label: "上传图片" },
+ { value: "preset", label: "系统头像" },
+ ]}
+ onChange={(tab) => {
+ setAvatarTab(tab);
+ setError("avatar", "");
+ }}
+ />
+ {avatarTab === "upload" ? (
+
+ {draftAvatar ? (
+

{
+ setDraftAvatar(null);
+ setError("avatar", "无法读取图片,请选择有效的 PNG 或 JPEG 图片");
+ }}
+ />
+ ) : (
+
+ )}
+
+
{draftAvatar ? "裁剪后的头像" : "当前头像"}
+
选择 PNG 或 JPEG 图片,可裁剪为正方形;文件最大 5 MiB。
+
+
+ } disabled={!!pending} onClick={() => fileInput.current?.click()}>
+ {draftAvatar ? "重新选择" : "选择图片"}
+
+ {draftAvatar ? (
+ } disabled={!!pending} onClick={() => setCropSource({ file: draftAvatar.sourceFile, url: URL.createObjectURL(draftAvatar.sourceFile) })}>
+ 调整裁剪
+
+ ) : null}
+
+
+
+ ) : (
+ <>
+ 选择一款系统头像,保存后会同步到工作区。
+
+ {DEFAULT_USER_AVATARS.map((preset) => (
+
+ ))}
+
+ >
+ )}
+ {errors.avatar ? (
+
+ {errors.avatar}
+
+ ) : null}
+
+
+ ) : null}
+ {cropSource ? (
+
{
+ if (!pendingRef.current) setCropSource(null);
+ }}
+ onConfirm={(crop) => void confirmAvatarCrop(crop)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/web/src/pages/settings/profile-validation.ts b/web/src/pages/settings/profile-validation.ts
new file mode 100644
index 000000000..6a4f26a12
--- /dev/null
+++ b/web/src/pages/settings/profile-validation.ts
@@ -0,0 +1,20 @@
+export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
+
+export function profileAvatarError(file: Pick): 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;
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 43184dd80..f9f88da53 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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: },
{ path: "users", element: },
+ { path: "login-logs", element: },
{ path: "channels", element: },
{ path: "models", element: },
{ path: "plugins", element: },
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index 99aaabfe2..804a3c53f 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -23,6 +23,8 @@ export type LocalUser = {
phoneVerifiedAt?: string;
displayName: string;
avatarUrl?: string;
+ avatarMode?: "custom" | "default" | "";
+ avatarPreset?: string;
identityProvider?: string;
identityId?: string;
identityUsername?: string;
diff --git a/web/src/services/api/login-logs.ts b/web/src/services/api/login-logs.ts
new file mode 100644
index 000000000..641f4bc97
--- /dev/null
+++ b/web/src/services/api/login-logs.ts
@@ -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 });
+}
diff --git a/web/src/services/api/profile.ts b/web/src/services/api/profile.ts
new file mode 100644
index 000000000..347a3132b
--- /dev/null
+++ b/web/src/services/api/profile.ts
@@ -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("/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("/auth/profile/email-code", input);
+export const changeProfileEmail = (input: { ticket: string; emailCode: string; currentPassword: string }) => http.post("/auth/profile/email", input);
+
+export const profileLinuxDOReauthURL = () => `${apiBaseURL.replace(/\/$/, "")}/auth/profile/linuxdo/start`;
diff --git a/web/src/services/profile-avatar.ts b/web/src/services/profile-avatar.ts
new file mode 100644
index 000000000..70e68ce07
--- /dev/null
+++ b/web/src/services/profile-avatar.ts
@@ -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 {
+ 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((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;
+ }
+ }
+}
diff --git a/web/src/services/profile-session.ts b/web/src/services/profile-session.ts
new file mode 100644
index 000000000..06752f572
--- /dev/null
+++ b/web/src/services/profile-session.ts
@@ -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;
+}
diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts
index a7e5f0375..8c9dd5261 100644
--- a/web/src/stores/use-user-store.ts
+++ b/web/src/stores/use-user-store.ts
@@ -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;
diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts
index 3db40e744..759399c7c 100644
--- a/web/test/admin-ui-regressions.test.ts
+++ b/web/test/admin-ui-regressions.test.ts
@@ -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)");
diff --git a/web/test/agent-launcher-position.test.mjs b/web/test/agent-launcher-position.test.mjs
index 7401dd33c..a445fc838 100644
--- a/web/test/agent-launcher-position.test.mjs
+++ b/web/test/agent-launcher-position.test.mjs
@@ -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: [
diff --git a/web/test/application-bootstrap.test.mjs b/web/test/application-bootstrap.test.mjs
index b1c7a894b..83db4cda3 100644
--- a/web/test/application-bootstrap.test.mjs
+++ b/web/test/application-bootstrap.test.mjs
@@ -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) },
diff --git a/web/test/login-log-device.test.ts b/web/test/login-log-device.test.ts
new file mode 100644
index 000000000..16e102118
--- /dev/null
+++ b/web/test/login-log-device.test.ts
@@ -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: "未记录" });
+ });
+});
diff --git a/web/test/profile-avatar-crop.test.ts b/web/test/profile-avatar-crop.test.ts
new file mode 100644
index 000000000..2d9bbe193
--- /dev/null
+++ b/web/test/profile-avatar-crop.test.ts
@@ -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) {
+ 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("");
+ });
+ });
+});
diff --git a/web/test/profile-session.test.ts b/web/test/profile-session.test.ts
new file mode 100644
index 000000000..a038da13a
--- /dev/null
+++ b/web/test/profile-session.test.ts
@@ -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 });
+ }
+});
diff --git a/web/test/profile-settings.test.ts b/web/test/profile-settings.test.ts
new file mode 100644
index 000000000..1e17925d5
--- /dev/null
+++ b/web/test/profile-settings.test.ts
@@ -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> = [];
+ apiClient.request = (async (config: Record) => {
+ 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> = [];
+ apiClient.request = (async (config: Record) => {
+ 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;
+ }
+ });
+});
diff --git a/web/test/user-avatar.test.tsx b/web/test/user-avatar.test.tsx
new file mode 100644
index 000000000..6bb0079ee
--- /dev/null
+++ b/web/test/user-avatar.test.tsx
@@ -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();
+}
+
+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(`
{
+ 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("
{
+ const markup = renderToStaticMarkup();
+ expect(markup).toContain("app-workspace-account-icon");
+ expect(markup).not.toContain("
{
+ 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(
+ ,
+ );
+ expect(markup).not.toContain("
{
+ const markup = renderToStaticMarkup();
+ expect(markup).toContain("
);
+ expect(legacyDefault).toContain("lucide-circle-user-round");
+ });
+});