feat(web): add persistent beam avatars
This commit is contained in:
@@ -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;
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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="当前默认星盘">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { UserAvatar } from "../src/components/user-avatar.tsx";
|
||||
import {
|
||||
BEAM_AVATAR_RENDERER_VERSION,
|
||||
beamAvatarPalettes,
|
||||
beamAvatarPatchSchema,
|
||||
beamAvatarSchema,
|
||||
createBeamAvatarProfilePatch,
|
||||
} from "../src/lib/beam-avatar.ts";
|
||||
|
||||
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const accountRoute = readProjectFile("src/app/api/account/route.ts");
|
||||
const avatarRoute = readProjectFile("src/app/api/account/avatar/route.ts");
|
||||
const avatarComponent = readProjectFile("src/components/user-avatar.tsx");
|
||||
const sidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
const page = readProjectFile("src/app/page.tsx");
|
||||
const migration = readProjectFile("db/migrations/20260807010000_profile_beam_avatars.sql");
|
||||
|
||||
const seed = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
test("defines eight reviewed Beam palettes and versioned persisted parameters", () => {
|
||||
assert.equal(beamAvatarPalettes.length, 8);
|
||||
assert.equal(new Set(beamAvatarPalettes.map((palette) => palette.name)).size, 8);
|
||||
assert.equal(beamAvatarPalettes.every((palette) => palette.colors.length === 5), true);
|
||||
assert.deepEqual(beamAvatarSchema.parse({
|
||||
seed,
|
||||
palette: 7,
|
||||
rendererVersion: BEAM_AVATAR_RENDERER_VERSION,
|
||||
}), { seed, palette: 7, rendererVersion: 1 });
|
||||
assert.equal(beamAvatarSchema.safeParse({ seed, palette: 8, rendererVersion: 1 }).success, false);
|
||||
assert.equal(beamAvatarSchema.safeParse({ seed: "email@example.test", palette: 0, rendererVersion: 1 }).success, false);
|
||||
});
|
||||
|
||||
test("renders identical Beam SVG for identical persisted parameters", () => {
|
||||
const avatar = { seed, palette: 0, rendererVersion: 1 } as const;
|
||||
const first = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
|
||||
const second = renderToStaticMarkup(createElement(UserAvatar, { avatar, size: 40 }));
|
||||
|
||||
assert.equal(first, second);
|
||||
assert.match(first, /<svg/);
|
||||
assert.match(first, /width="40"/);
|
||||
assert.match(first, /height="40"/);
|
||||
});
|
||||
|
||||
test("accepts only bounded avatar actions and creates new seeds on the server", () => {
|
||||
assert.equal(beamAvatarPatchSchema.safeParse({ action: "set_palette", palette: 3 }).success, true);
|
||||
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize" }).success, true);
|
||||
assert.equal(beamAvatarPatchSchema.safeParse({ action: "randomize", seed }).success, false);
|
||||
assert.equal(beamAvatarPatchSchema.safeParse({ action: "upload" }).success, false);
|
||||
|
||||
assert.deepEqual(
|
||||
createBeamAvatarProfilePatch({ action: "set_palette", palette: 3 }, () => seed),
|
||||
{ avatar_palette: 3, avatar_renderer_version: 1 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
createBeamAvatarProfilePatch({ action: "randomize" }, () => seed),
|
||||
{ avatar_renderer_version: 1, avatar_seed: seed },
|
||||
);
|
||||
});
|
||||
|
||||
test("persists random defaults and constraints only for self-hosted profiles", () => {
|
||||
assert.match(migration, /to_regclass\('identity\.users'\) is not null/);
|
||||
assert.match(migration, /avatar_seed uuid not null default gen_random_uuid\(\)/);
|
||||
assert.match(migration, /avatar_palette smallint not null[\s\S]*floor\(random\(\) \* 8\)/);
|
||||
assert.match(migration, /check \(avatar_palette between 0 and 7\)/);
|
||||
assert.match(migration, /check \(avatar_renderer_version = 1\)/);
|
||||
assert.match(migration, /grant update \([\s\S]*avatar_seed[\s\S]*avatar_palette[\s\S]*avatar_renderer_version[\s\S]*\) on table public\.profiles to authenticated/);
|
||||
assert.doesNotMatch(migration, /bytea|storage|bucket|upload/i);
|
||||
});
|
||||
|
||||
test("account APIs expose and update Beam parameters through authenticated staging data", () => {
|
||||
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
||||
assert.match(accountRoute, /select\("avatar_seed,avatar_palette,avatar_renderer_version"\)/);
|
||||
assert.match(accountRoute, /avatar,/);
|
||||
|
||||
assert.match(avatarRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
|
||||
assert.match(avatarRoute, /createServerSupabaseClient\(\)/);
|
||||
assert.match(avatarRoute, /auth\.getUser\(\)/);
|
||||
assert.match(avatarRoute, /beamAvatarPatchSchema\.safeParse/);
|
||||
assert.match(avatarRoute, /createBeamAvatarProfilePatch\(parsed\.data\)/);
|
||||
assert.match(avatarRoute, /\.eq\("id", user\.id\)/);
|
||||
assert.doesNotMatch(avatarRoute, /createAdminSupabaseClient|SUPABASE_SERVICE_ROLE_KEY|upload|bytea/i);
|
||||
});
|
||||
|
||||
test("renders the persisted Beam avatar in the sidebar, account menu, and profile editor", () => {
|
||||
assert.match(avatarComponent, /from "boring-avatars"/);
|
||||
assert.match(avatarComponent, /variant="beam"/);
|
||||
assert.match(avatarComponent, /name=\{avatar\.seed\}/);
|
||||
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{32\}/);
|
||||
assert.match(sidebar, /<UserAvatar avatar=\{account\.avatar\} size=\{40\}/);
|
||||
assert.match(page, /Beam 形象由随机种子生成/);
|
||||
assert.match(page, /role="radiogroup" aria-label="头像配色"/);
|
||||
assert.match(page, /persistAvatar\(\{ action: "set_palette", palette: index \}\)/);
|
||||
assert.match(page, /persistAvatar\(\{ action: "randomize" \}\)/);
|
||||
assert.doesNotMatch(page, /avatar[^\n]{0,80}(?:file|upload|上传图片)/i);
|
||||
});
|
||||
Reference in New Issue
Block a user