feat(web): add persistent beam avatars
Staging Backend Quality Gate / validate (push) Successful in 13m34s
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse
2026-08-07 12:07:57 +08:00
parent e7482c49c6
commit 49f6edd7fb
11 changed files with 420 additions and 2 deletions
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
beamAvatarFromProfile,
beamAvatarPatchSchema,
createBeamAvatarProfilePatch,
} from "@/lib/beam-avatar";
export const runtime = "nodejs";
function selfHostedOnly() {
return process.env.AUTH_PROVIDER?.trim() === "self-hosted";
}
export async function PATCH(request: Request) {
if (!selfHostedOnly()) {
return NextResponse.json({ error: "头像编辑仅在 staging 开放" }, { status: 404 });
}
try {
const client = await createServerSupabaseClient();
const { data: { user }, error: authError } = await client.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const parsed = beamAvatarPatchSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json({
error: "头像设置格式不正确",
details: parsed.error.flatten(),
}, { status: 400 });
}
const values = {
...createBeamAvatarProfilePatch(parsed.data),
updated_at: new Date().toISOString(),
};
const { data: profile, error } = await client
.from("profiles")
.update(values)
.eq("id", user.id)
.select("avatar_seed,avatar_palette,avatar_renderer_version")
.maybeSingle();
if (error || !profile) {
return NextResponse.json({ error: "暂时无法保存头像" }, { status: 500 });
}
return NextResponse.json({ avatar: beamAvatarFromProfile(profile) });
} catch {
return NextResponse.json({ error: "头像服务暂时不可用" }, { status: 500 });
}
}
+19
View File
@@ -8,6 +8,7 @@ import {
applyAccountProfileConcurrencyGuards,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { optionalBeamAvatarFromProfile } from "@/lib/beam-avatar";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
@@ -110,6 +111,23 @@ export async function GET() {
if (profileError || !profile) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
let avatar = null;
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
const { data: avatarProfile, error: avatarError } = await supabase
.from("profiles")
.select("avatar_seed,avatar_palette,avatar_renderer_version")
.eq("id", userId)
.single();
if (avatarError || !avatarProfile) {
return NextResponse.json({ error: "暂时无法读取头像" }, { status: 500 });
}
avatar = optionalBeamAvatarFromProfile(avatarProfile);
if (!avatar) {
return NextResponse.json({ error: "头像设置无效" }, { status: 500 });
}
}
const rectificationCase = resolveAccountRectificationV4Case(
Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
) ?? resolveAccountRectificationCase(
@@ -134,6 +152,7 @@ export async function GET() {
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
avatar,
credits: profile.credits,
isAdmin,
adminUrl,
+20
View File
@@ -345,6 +345,9 @@ button:disabled { cursor: default; opacity: .45; }
.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); }
.sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
.user-avatar { display: inline-grid; flex: 0 0 auto; overflow: hidden; place-items: center; border-radius: 50%; background: var(--color-action-soft); }
.user-avatar > svg { width: 100%; height: 100%; display: block; }
.profile-avatar { width: 32px; height: 32px; }
.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; }
.profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; }
[data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; }
@@ -709,6 +712,7 @@ button:disabled { cursor: default; opacity: .45; }
.account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); }
.account-menu-identity { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); margin-bottom: var(--space-1); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.account-menu-avatar { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; }
.user-avatar.account-menu-avatar { overflow: hidden; }
.account-menu-identity > span:last-child { min-width: 0; }
.account-menu-identity b, .account-menu-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-menu-identity b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
@@ -732,6 +736,19 @@ button:disabled { cursor: default; opacity: .45; }
.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
.profile-modal .birth-section { padding-top: var(--space-5); }
.avatar-section { padding-top: 0; }
.avatar-editor { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: var(--space-5); margin-top: var(--space-5); }
.avatar-editor-controls { min-width: 0; display: grid; gap: var(--space-4); }
.avatar-palette-list { display: grid; grid-template-columns: repeat(4, minmax(48px, 1fr)); gap: var(--space-2); }
.avatar-palette { min-width: 0; height: 34px; overflow: hidden; display: flex; padding: 3px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); cursor: pointer; transition: border-color 120ms ease-out, box-shadow 120ms ease-out, transform 120ms ease-out; }
.avatar-palette > span { min-width: 0; flex: 1; }
.avatar-palette > span:first-child { border-radius: calc(var(--radius-md) - 4px) 0 0 calc(var(--radius-md) - 4px); }
.avatar-palette > span:last-child { border-radius: 0 calc(var(--radius-md) - 4px) calc(var(--radius-md) - 4px) 0; }
.avatar-palette[aria-checked="true"] { border-color: var(--color-action); box-shadow: 0 0 0 2px var(--color-action-soft); }
.avatar-palette:disabled { cursor: wait; opacity: .65; }
.avatar-palette:not(:disabled):active { transform: scale(.96); }
.avatar-randomize { justify-self: start; }
.avatar-notice { margin-top: var(--space-4); }
.redeem-modal { width: min(100%, 420px); }
.logout-modal { width: min(100%, 400px); }
.account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
@@ -866,6 +883,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
.avatar-editor > .user-avatar { width: 72px !important; height: 72px !important; }
.avatar-palette-list { grid-template-columns: repeat(2, minmax(64px, 1fr)); }
.auth-page { padding: 0; }
.auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; }
.auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); }
+76
View File
@@ -8,6 +8,7 @@ import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { UserAvatar } from "@/components/user-avatar";
import {
BirthTimeAssessmentOverlay,
type BirthTimeAssessmentPhase,
@@ -28,6 +29,12 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
import {
beamAvatarPalettes,
beamAvatarSchema,
type BeamAvatar,
type BeamAvatarPatch,
} from "@/lib/beam-avatar";
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
import {
applyBirthTimeDraftPatch,
@@ -179,6 +186,7 @@ type BirthPlace = {
};
type Account = {
user: { id: string; email: string | null };
avatar: BeamAvatar | null;
credits: number;
isAdmin: boolean;
adminUrl: string | null;
@@ -933,6 +941,8 @@ export default function Home() {
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
const [redeemCode, setRedeemCode] = useState("");
@@ -1257,6 +1267,7 @@ export default function Home() {
};
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
avatar: null,
credits: 8,
isAdmin: false,
adminUrl: null,
@@ -1692,6 +1703,7 @@ export default function Home() {
case "profile":
setProfileDraft(profile);
setProfileNotice("");
setAvatarNotice("");
break;
case "redeem":
setRedeemError("");
@@ -1718,6 +1730,30 @@ export default function Home() {
window.requestAnimationFrame(() => returnTarget?.focus());
}
async function persistAvatar(patch: BeamAvatarPatch) {
if (!account?.avatar || avatarSaving) return;
setAvatarSaving(true);
setAvatarNotice("");
setAccountError("");
try {
const response = await fetch("/api/account/avatar", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。");
const avatar = beamAvatarSchema.parse(payload?.avatar);
setAccount((current) => current ? { ...current, avatar } : current);
setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败"));
} finally {
setAvatarSaving(false);
}
}
async function persistProfile(nextProfile: Profile) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
@@ -2769,6 +2805,7 @@ export default function Home() {
credits: account.credits,
isAdmin: account.isAdmin,
adminUrl: account.adminUrl,
avatar: account.avatar,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
|| "你",
@@ -3153,6 +3190,45 @@ export default function Home() {
{activeAccountDialog === "profile" && (
<>
{accountError && <p className="form-error" role="alert">{accountError}</p>}
{account.avatar && (
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title"></b>
<small>Beam </small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={96} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
key={palette.name}
className="avatar-palette"
type="button"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
title={palette.name}
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
))}
</div>
<button
className="button-secondary avatar-randomize"
type="button"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</div>
</div>
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
</section>
)}
<section className="sheet-section birth-section">
<div className="section-heading"><b></b><small></small></div>
<div className="default-chart-card" aria-label="当前默认星盘">
+9 -2
View File
@@ -30,12 +30,15 @@ import {
type SidebarSession,
type SidebarSessionControls,
} from "@/components/sidebar-session-row";
import { UserAvatar } from "@/components/user-avatar";
import type { BeamAvatar } from "@/lib/beam-avatar";
export type SidebarAccount = {
name: string;
email: string;
credits: number;
initial: string;
avatar: BeamAvatar | null;
};
export type AppSidebarProps = {
@@ -183,7 +186,9 @@ export function AppSidebar({
ref={accountTriggerRef}
type="button"
>
<span className="profile-initial" aria-hidden="true">{account.initial}</span>
{account.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
</Menu.Trigger>
@@ -196,7 +201,9 @@ export function AppSidebar({
>
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
<div className="account-menu-identity">
<span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import Avatar from "boring-avatars";
import { beamAvatarPalettes, type BeamAvatar } from "@/lib/beam-avatar";
export type UserAvatarProps = {
avatar: BeamAvatar;
size: number;
className?: string;
label?: string;
};
export function UserAvatar({ avatar, size, className, label }: UserAvatarProps) {
const palette = beamAvatarPalettes[avatar.palette] ?? beamAvatarPalettes[0];
return (
<span
className={className ? `user-avatar ${className}` : "user-avatar"}
style={{ width: size, height: size }}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<Avatar
name={avatar.seed}
variant="beam"
colors={[...palette.colors]}
size={size}
/>
</span>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
export const BEAM_AVATAR_RENDERER_VERSION = 1 as const;
export const beamAvatarPalettes = [
{ name: "星砂", colors: ["#17324D", "#2D6073", "#D5A45D", "#F2E6CF", "#B9684A"] },
{ name: "靛夜", colors: ["#202842", "#495B8C", "#93A3D1", "#E4D9C7", "#C58D68"] },
{ name: "莲火", colors: ["#553640", "#A35D63", "#D99B7A", "#F2D8BA", "#5E7161"] },
{ name: "松雾", colors: ["#1F3D3A", "#4D746C", "#A7B59A", "#E8DEC8", "#C77B56"] },
{ name: "晨曦", colors: ["#34405F", "#7484A6", "#E2AF72", "#F4E5C9", "#B75D54"] },
{ name: "紫藤", colors: ["#3E3555", "#77638F", "#B9A3CA", "#E9DCCB", "#C47B6C"] },
{ name: "海盐", colors: ["#1D4554", "#4F8090", "#A8C0BD", "#EFE2C8", "#D18A5D"] },
{ name: "桂影", colors: ["#443B2D", "#7B6848", "#C6A25E", "#EFE0BE", "#8C5B4C"] },
] as const;
export const beamAvatarPaletteSchema = z.number().int().min(0).max(beamAvatarPalettes.length - 1);
export const beamAvatarSchema = z.object({
seed: z.string().uuid(),
palette: beamAvatarPaletteSchema,
rendererVersion: z.literal(BEAM_AVATAR_RENDERER_VERSION),
}).strict();
export const beamAvatarPatchSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("set_palette"),
palette: beamAvatarPaletteSchema,
}).strict(),
z.object({
action: z.literal("randomize"),
palette: beamAvatarPaletteSchema.optional(),
}).strict(),
]);
export type BeamAvatar = z.infer<typeof beamAvatarSchema>;
export type BeamAvatarPatch = z.infer<typeof beamAvatarPatchSchema>;
export function createBeamAvatarProfilePatch(
patch: BeamAvatarPatch,
createSeed: () => string = () => globalThis.crypto.randomUUID(),
) {
return {
...(patch.palette !== undefined ? { avatar_palette: patch.palette } : {}),
avatar_renderer_version: BEAM_AVATAR_RENDERER_VERSION,
...(patch.action === "randomize" ? { avatar_seed: createSeed() } : {}),
};
}
export function beamAvatarFromProfile(profile: Record<string, unknown>): BeamAvatar {
return beamAvatarSchema.parse({
seed: profile.avatar_seed,
palette: profile.avatar_palette,
rendererVersion: profile.avatar_renderer_version,
});
}
export function optionalBeamAvatarFromProfile(
profile: Record<string, unknown>,
): BeamAvatar | null {
const parsed = beamAvatarSchema.safeParse({
seed: profile.avatar_seed,
palette: profile.avatar_palette,
rendererVersion: profile.avatar_renderer_version,
});
return parsed.success ? parsed.data : null;
}