From 49f6edd7fb2700edf8539ec7ffa48bcf783c2035 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 7 Aug 2026 12:07:57 +0800 Subject: [PATCH] feat(web): add persistent beam avatars --- .../20260807010000_profile_beam_avatars.sql | 30 ++++++ frontend/package-lock.json | 11 ++ frontend/package.json | 1 + frontend/src/app/api/account/avatar/route.ts | 56 ++++++++++ frontend/src/app/api/account/route.ts | 19 ++++ frontend/src/app/globals.css | 20 ++++ frontend/src/app/page.tsx | 76 +++++++++++++ frontend/src/components/app-sidebar.tsx | 11 +- frontend/src/components/user-avatar.tsx | 32 ++++++ frontend/src/lib/beam-avatar.ts | 66 ++++++++++++ frontend/tests/beam-avatar.test.ts | 100 ++++++++++++++++++ 11 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 frontend/db/migrations/20260807010000_profile_beam_avatars.sql create mode 100644 frontend/src/app/api/account/avatar/route.ts create mode 100644 frontend/src/components/user-avatar.tsx create mode 100644 frontend/src/lib/beam-avatar.ts create mode 100644 frontend/tests/beam-avatar.test.ts diff --git a/frontend/db/migrations/20260807010000_profile_beam_avatars.sql b/frontend/db/migrations/20260807010000_profile_beam_avatars.sql new file mode 100644 index 00000000..96088add --- /dev/null +++ b/frontend/db/migrations/20260807010000_profile_beam_avatars.sql @@ -0,0 +1,30 @@ +begin; + +do $migration$ +begin + if to_regclass('identity.users') is not null + and to_regclass('public.profiles') is not null then + alter table public.profiles + add column if not exists avatar_seed uuid not null default gen_random_uuid(), + add column if not exists avatar_palette smallint not null + default (floor(random() * 8)::smallint), + add column if not exists avatar_renderer_version smallint not null default 1; + + alter table public.profiles + drop constraint if exists profiles_avatar_palette_check, + add constraint profiles_avatar_palette_check + check (avatar_palette between 0 and 7), + drop constraint if exists profiles_avatar_renderer_version_check, + add constraint profiles_avatar_renderer_version_check + check (avatar_renderer_version = 1); + + grant update ( + avatar_seed, + avatar_palette, + avatar_renderer_version + ) on table public.profiles to authenticated; + end if; +end +$migration$; + +commit; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da1047c3..dc6dbdcd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", "better-auth": "1.6.23", + "boring-avatars": "2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -4115,6 +4116,16 @@ "node": ">=6.0.0" } }, + "node_modules/boring-avatars": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/boring-avatars/-/boring-avatars-2.0.4.tgz", + "integrity": "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/better-auth": { "version": "1.6.23", "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9f1c2ea1..011de5d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@tailwindcss/postcss": "^4.3.2", "antd": "^5.29.3", "better-auth": "1.6.23", + "boring-avatars": "2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", diff --git a/frontend/src/app/api/account/avatar/route.ts b/frontend/src/app/api/account/avatar/route.ts new file mode 100644 index 00000000..da72d7c0 --- /dev/null +++ b/frontend/src/app/api/account/avatar/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 00a5a2c4..d6cce26f 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -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, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 09ed9928..3fc51b47 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5f8a0629..e7343322 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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([]); const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); const [profileNotice, setProfileNotice] = useState(""); + const [avatarNotice, setAvatarNotice] = useState(""); + const [avatarSaving, setAvatarSaving] = useState(false); const [account, setAccount] = useState(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 &&

{accountError}

} + {account.avatar && ( +
+
+ 头像 + Beam 形象由随机种子生成,刷新和换设备后保持一致 +
+
+ +
+
+ {beamAvatarPalettes.map((palette, index) => ( + + ))} +
+ +
+
+ {avatarNotice &&

{avatarNotice}

} +
+ )}
出生资料加密传输并保存到云端,用于此账号的所有对话
diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index b3d5f902..8deba331 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -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" > - + {account.avatar + ? + : } {showExpandedContent ? {account.name} : null} {showExpandedContent ?