diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6ec1d25d..9fdc70d5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2400,3 +2400,19 @@ - 相关记录:BUG-123、BUG-134、BUG-138 - 复发自:BUG-123 - 修复版本:本次 staging admin redirect/RBAC recovery 候选提交 + +## BUG-140 | 首页入口卡片点击后持续显示灰色交互态 + +- 状态:resolved +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:首页 `starter-theme-card`、`daily-starlanguage-card` 与共享 `product-entrypoint-card` 交互反馈 +- 用户现象:用户点击首页主题或产品入口卡片后,卡片背景持续处于灰色/选中样式,没有在松开指针后恢复。 +- 触发条件:在触摸设备点击卡片,或在桌面端点击后让指针继续停留在卡片上。 +- 根因:卡片把铺满表面的背景反馈绑定到无设备能力边界的 `:hover`;移动浏览器可能在点击后保留模拟 hover,桌面指针停留也会让一次点击看起来像永久选中。`hover`、短暂按压和真实 disabled 三种状态因此在视觉上混在一起。 +- 修复:将铺灰背景收敛到 `:active`,只在按压期间显示并于释放后恢复;桌面 `@media (hover: hover)` 只保留轻量边框、光泽和箭头位移,不再改变卡片底色;键盘继续使用现有 `focus-visible` 轮廓,真实 disabled 透明度规则保持不变。 +- 验证:`frontend/tests/consultation-entrypoint.test.ts` 新增 sticky-hover 回归;与 `frontend/tests/starter-questions.test.ts` 精确运行 40/40 通过;目标 ESLint 与 `git diff --check` 通过。全量前端尝试中 1457 项通过,16 项因本机缺少 Docker 或 `python` 可执行命令而失败,与本次 CSS 修改无关。 +- 防复发:首页可点击卡片的表面底色只能由短暂 `:active` 或真实业务 disabled 状态改变;hover 视觉必须限制在支持 hover 的设备,且回归测试禁止重新为这些卡片的 hover surface 添加背景色。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待提交 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 6b369c65..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; } @@ -550,9 +553,11 @@ button:disabled { cursor: default; opacity: .45; } .product-entrypoint-card { position: relative; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } .product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(-1px); } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { transform: translateY(0); } +.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); } .product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; } +@media (hover: hover) { + .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); transform: translateY(-1px); } +} .product-entrypoint-card:has(.product-entrypoint-hitarea:disabled) { opacity: .65; } .product-entrypoint-hitarea:disabled { cursor: not-allowed; } .daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } @@ -707,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; } @@ -730,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; } @@ -864,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); } @@ -1119,19 +1141,25 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: pointer-events: none; } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { +.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: transparent; background: color-mix(in srgb, var(--color-action-soft) 58%, var(--color-canvas)); - transform: none; } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover)::after { - opacity: .65; - transform: translateX(48%); -} +@media (hover: hover) { + .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) { + border-color: transparent; + transform: none; + } -.product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) .product-entrypoint-action { - transform: translateX(3px); + .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover)::after { + opacity: .65; + transform: translateX(48%); + } + + .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):hover) .product-entrypoint-action { + transform: translateX(3px); + } } .product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { @@ -1270,17 +1298,22 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: border-right: 0; } -.starter-theme-card:not(:disabled):hover, -.starter-theme-card:focus-visible { +.starter-theme-card:not(:disabled):active { background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas)); } -.starter-theme-card:not(:disabled):hover .starter-arrow, -.starter-theme-card:focus-visible .starter-arrow { +.starter-theme-card:not(:disabled):active .starter-arrow { color: var(--color-action); transform: translate(2px, -2px); } +@media (hover: hover) { + .starter-theme-card:not(:disabled):hover .starter-arrow { + color: var(--color-action); + transform: translate(2px, -2px); + } +} + .starter-content { min-width: 0; display: grid; @@ -1438,11 +1471,6 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: border-bottom: 0; } - .starter-theme-card:not(:disabled):hover, - .starter-theme-card:focus-visible { - background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas)); - } - .starter-content span { max-width: 440px; } 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 ?