Compare commits

...

2 Commits

Author SHA1 Message Date
admin 4c512d323e chore(favicon): 使用预发布环境的图标
Docker Image / Quality and Bundle Budgets (push) Successful in 4s
Docker Image / Build and Push Docker Image (push) Successful in 21s
2026-07-21 18:36:50 +08:00
admin 7f271ef2e0 feat(chat): show user avatar in profile action
Render the stored user avatar in the Chat Header when available while preserving the existing Profile icon fallback, navigation, accessibility label, and analytics.
2026-07-21 18:36:45 +08:00
5 changed files with 57 additions and 18 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@@ -1,6 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character"; import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider"; import { CharacterProvider } from "@/providers/character-provider";
@@ -18,13 +18,27 @@ import { PrivateMessageCard } from "../private-message-card";
import { PwaInstallDialog } from "../pwa-install-dialog"; import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble"; import { TextBubble } from "../text-bubble";
const userMocks = vi.hoisted(() => ({
avatarUrl: null as string | null,
}));
vi.mock("@/router/use-app-navigator", () => ({ vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({ useAppNavigator: () => ({
openSubscription: vi.fn(), openSubscription: vi.fn(),
}), }),
})); }));
vi.mock("@/stores/user/user-context", () => ({
useUserSelector: (
selector: (state: { context: { avatarUrl: string | null } }) => unknown,
) => selector({ context: { avatarUrl: userMocks.avatarUrl } }),
}));
describe("chat Tailwind components", () => { describe("chat Tailwind components", () => {
beforeEach(() => {
userMocks.avatarUrl = null;
});
it("renders MessageAvatar AI and user branches with image utilities", () => { it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />); const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
const userHtml = renderWithCharacter( const userHtml = renderWithCharacter(
@@ -228,6 +242,7 @@ describe("chat Tailwind components", () => {
expect(memberHtml).not.toContain("px-(--spacing-md,12px)"); expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
expect(memberHtml).toContain("lucide-user-round");
expect(memberWithHintHtml).toContain( expect(memberWithHintHtml).toContain(
'data-analytics-key="chat.external_browser_hint"', 'data-analytics-key="chat.external_browser_hint"',
); );
@@ -249,6 +264,20 @@ describe("chat Tailwind components", () => {
); );
}); });
it("renders the user avatar in the ChatHeader Profile action", () => {
userMocks.avatarUrl = "/images/avatar/profile-user.png";
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png");
expect(html).toContain('aria-label="Profile"');
expect(html).toContain('data-analytics-key="chat.open_profile"');
expect(html).toContain(
"var(--responsive-icon-button-size, 42px)",
);
expect(html).not.toContain("lucide-user-round");
});
it("links guest chat back to the active character splash", () => { it("links guest chat back to the active character splash", () => {
const maya = getCharacterBySlug("maya"); const maya = getCharacterBySlug("maya");
if (!maya) throw new Error("Missing Maya character fixture"); if (!maya) throw new Error("Missing Maya character fixture");
+27 -17
View File
@@ -2,17 +2,17 @@
/** /**
* ChatHeader 顶部栏 * ChatHeader 顶部栏
* *
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile * Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />
* tree-shakablecurrentColor 继承父 color
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, UserRound } from "lucide-react"; import { Lock, UserRound } from "lucide-react";
import { BackButton } from "@/app/_components"; import { BackButton, UserMessageAvatar } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { buildGlobalPageUrl } from "@/router/global-route-context"; import { buildGlobalPageUrl } from "@/router/global-route-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import { BrowserHintOverlay } from "./browser-hint-overlay"; import { BrowserHintOverlay } from "./browser-hint-overlay";
@@ -29,6 +29,10 @@ export function ChatHeader({
}: ChatHeaderProps) { }: ChatHeaderProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
const handleOpenProfile = () => {
navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat));
};
return ( return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0"> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -72,20 +76,26 @@ export function ChatHeader({
{showBrowserHint ? <BrowserHintOverlay /> : null} {showBrowserHint ? <BrowserHintOverlay /> : null}
</div> </div>
<button {avatarUrl ? (
type="button" <UserMessageAvatar
data-analytics-key="chat.open_profile" avatarUrl={avatarUrl}
data-analytics-label="Open profile" size="var(--responsive-icon-button-size, 42px)"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]" actionLabel="Profile"
onClick={() => analyticsKey="chat.open_profile"
navigator.push( onClick={handleOpenProfile}
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat), />
) ) : (
} <button
aria-label="Profile" type="button"
> data-analytics-key="chat.open_profile"
<UserRound size={24} aria-hidden="true" /> data-analytics-label="Open profile"
</button> className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={handleOpenProfile}
aria-label="Profile"
>
<UserRound size={24} aria-hidden="true" />
</button>
)}
</> </>
) : null} ) : null}
</div> </div>