feat(ui): optional gender radio in the people form and 个人资料 (T2)
One ProfileGenderField (女 / 男 / 不填, 44px, one hint line) in the people-archive edit form (saved with the form, 不填 → null) and in 设置 → 个人资料 for the owner (saves on its own PATCH; birth-data saves never send gender). The detail card shows 性别 (未填 when empty). Not asked in onboarding. DESIGN/VOICE updated; 390/1280 screenshots with fictional data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5caf03844e
commit
d79dada970
@@ -313,6 +313,7 @@ export default function Home() {
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
saveProfile,
|
||||
saveOnboardingName,
|
||||
saveOnboardingBirth,
|
||||
@@ -1134,7 +1135,7 @@ export default function Home() {
|
||||
overlayRef: accountDialog,
|
||||
closeButtonRef: closeButton,
|
||||
renderProfile: () => (
|
||||
<ProfilePanel account={account} profile={profile} avatarSaving={avatarSaving} avatarNotice={avatarNotice} accountError={accountError} persistAvatar={persistAvatar} />
|
||||
<ProfilePanel account={account} profile={profile} avatarSaving={avatarSaving} avatarNotice={avatarNotice} accountError={accountError} persistAvatar={persistAvatar} persistGender={persistGender} />
|
||||
),
|
||||
renderChartLibrary() {
|
||||
return null;
|
||||
|
||||
@@ -1882,6 +1882,17 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.ayanamsa-preference-option[aria-checked="true"] { border-color: var(--color-action); background: var(--color-action-soft); }
|
||||
.ayanamsa-preference-option small { display: block; margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; }
|
||||
.ayanamsa-preference-hint { margin: var(--space-3) 0 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
|
||||
/* Optional gender (TASK-consult-gender-optional-20260927): three 44px choices
|
||||
in one row, same look as the birth-time range choices; the title matches the
|
||||
profile form's field labels, the hint matches the ayanamsa hint. */
|
||||
.profile-gender-field { display: grid; gap: var(--space-2); }
|
||||
.profile-gender-title { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 600; }
|
||||
.profile-gender-options { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.profile-gender-option { min-width: 64px; min-height: 44px; padding: 0 var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font-size: var(--type-body-sm); cursor: pointer; }
|
||||
.profile-gender-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); }
|
||||
.profile-gender-option:disabled { cursor: default; opacity: 0.6; }
|
||||
.profile-gender-hint { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
|
||||
.people-archive-fact-note { display: block; margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); }
|
||||
.account-menu-danger[data-highlighted] { background: var(--color-danger-muted); }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import { AyanamsaPreferenceField } from "@/components/ayanamsa-preference-field";
|
||||
import { ProfileFields } from "@/components/profile-fields";
|
||||
import { ProfileGenderField } from "@/components/profile-gender-field";
|
||||
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
|
||||
@@ -13,6 +14,8 @@ export type ChartProfileFormProps = {
|
||||
readonly onChange: Dispatch<SetStateAction<Profile>>;
|
||||
readonly nameInputId: string;
|
||||
readonly showAyanamsa?: boolean;
|
||||
/** Optional gender (女 / 男 / 不填); saved with the form. */
|
||||
readonly showGender?: boolean;
|
||||
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly onCancel?: () => void;
|
||||
readonly cancelLabel?: string;
|
||||
@@ -28,6 +31,7 @@ export function ChartProfileForm({
|
||||
onChange,
|
||||
nameInputId,
|
||||
showAyanamsa = false,
|
||||
showGender = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
cancelLabel = "取消",
|
||||
@@ -39,6 +43,13 @@ export function ChartProfileForm({
|
||||
<form className="profile-form chart-library-form" onSubmit={onSubmit}>
|
||||
<div className="section-heading"><b>{title}</b><small>{description}</small></div>
|
||||
<ProfileFields value={value} onChange={onChange} nameInputId={nameInputId} />
|
||||
{showGender ? (
|
||||
<ProfileGenderField
|
||||
idPrefix={nameInputId}
|
||||
value={value.gender}
|
||||
onChange={(gender) => onChange((current) => ({ ...current, gender }))}
|
||||
/>
|
||||
) : null}
|
||||
{showAyanamsa ? (
|
||||
<AyanamsaPreferenceField
|
||||
value={resolveAyanamsa(value)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
peopleTimeRectified,
|
||||
} from "@/lib/people-archive-view";
|
||||
import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
import { profileGenderLabel } from "@/lib/profile-gender";
|
||||
|
||||
export type PersonRow = {
|
||||
readonly id: string;
|
||||
@@ -40,6 +41,8 @@ const DELETE_FAILED = "删除失败,请重试";
|
||||
const NEW_PERSON_PARAM = "new";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
/** 本人 has no edit view here; the owner's gender is set in 设置 → 个人资料. */
|
||||
const SELF_GENDER_NOTE = "在「设置 → 个人资料」里改";
|
||||
|
||||
/** D1: below this width the page is list → detail; at and above it, two columns. */
|
||||
const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)";
|
||||
@@ -402,6 +405,7 @@ export function PeopleArchiveView({
|
||||
onChange={onDraftChange}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
showGender
|
||||
onSubmit={(event) => void saveOther(event)}
|
||||
onCancel={onCancelEdit}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
@@ -433,6 +437,11 @@ export function PeopleArchiveView({
|
||||
<dd>{effectiveBirthDate(selected.profile) || "出生日期待补全"}</dd>
|
||||
<dt>岁差</dt>
|
||||
<dd>{peopleAyanamsaLabel(selected.profile)}</dd>
|
||||
<dt>性别</dt>
|
||||
<dd>
|
||||
{profileGenderLabel(selected.profile.gender)}
|
||||
{selected.role === "self" ? <small className="people-archive-fact-note">{SELF_GENDER_NOTE}</small> : null}
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="people-archive-actions">
|
||||
<button type="button" onClick={() => openPerson(selected.id, newChatHref())}>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PROFILE_GENDER_HINT,
|
||||
PROFILE_GENDER_LABELS,
|
||||
PROFILE_GENDER_TITLE,
|
||||
PROFILE_GENDER_UNSET_LABEL,
|
||||
type ProfileGender,
|
||||
} from "@/lib/profile-gender";
|
||||
|
||||
const OPTIONS: ReadonlyArray<{ readonly value: ProfileGender | null; readonly label: string }> = [
|
||||
{ value: "female", label: PROFILE_GENDER_LABELS.female },
|
||||
{ value: "male", label: PROFILE_GENDER_LABELS.male },
|
||||
{ value: null, label: PROFILE_GENDER_UNSET_LABEL },
|
||||
];
|
||||
|
||||
/**
|
||||
* Optional gender (TASK-consult-gender-optional-20260927): 女 / 男 / 不填.
|
||||
* 不填 is stored as null. One line under the choices says what it is for.
|
||||
*/
|
||||
export function ProfileGenderField({
|
||||
value,
|
||||
onChange,
|
||||
idPrefix,
|
||||
disabled = false,
|
||||
}: {
|
||||
readonly value: ProfileGender | null | undefined;
|
||||
readonly onChange: (next: ProfileGender | null) => void;
|
||||
readonly idPrefix: string;
|
||||
readonly disabled?: boolean;
|
||||
}) {
|
||||
const titleId = `${idPrefix}-gender-title`;
|
||||
const hintId = `${idPrefix}-gender-hint`;
|
||||
const current = value ?? null;
|
||||
return (
|
||||
<div className="profile-gender-field">
|
||||
<span className="profile-gender-title" id={titleId}>{PROFILE_GENDER_TITLE}</span>
|
||||
<div className="profile-gender-options" role="radiogroup" aria-labelledby={titleId} aria-describedby={hintId}>
|
||||
{OPTIONS.map((option) => {
|
||||
const selected = option.value === current;
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
className={`profile-gender-option${selected ? " is-selected" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!selected) onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="profile-gender-hint" id={hintId}>{PROFILE_GENDER_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProfileGenderField } from "@/components/profile-gender-field";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { beamAvatarPalettes, type BeamAvatarPatch } from "@/lib/beam-avatar";
|
||||
import type { Account, Profile } from "@/lib/home-types";
|
||||
import type { ProfileGender } from "@/lib/profile-gender";
|
||||
|
||||
export type ProfilePanelProps = {
|
||||
readonly account: Account;
|
||||
@@ -11,6 +15,8 @@ export type ProfilePanelProps = {
|
||||
readonly avatarNotice: string;
|
||||
readonly accountError: string;
|
||||
readonly persistAvatar: (patch: BeamAvatarPatch) => void;
|
||||
/** Saves the owner's optional gender on its own (not part of any birth-data save). */
|
||||
readonly persistGender?: (gender: ProfileGender | null) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ProfilePanel({
|
||||
@@ -20,6 +26,7 @@ export function ProfilePanel({
|
||||
avatarNotice,
|
||||
accountError,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
}: ProfilePanelProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -70,6 +77,40 @@ export function ProfilePanel({
|
||||
<div><dt>登录邮箱</dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{persistGender ? <OwnerGenderSection value={profile.gender ?? null} persistGender={persistGender} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 本人的性别(选填): saved as soon as a choice is made, like the avatar palette. */
|
||||
function OwnerGenderSection({
|
||||
value,
|
||||
persistGender,
|
||||
}: {
|
||||
readonly value: ProfileGender | null;
|
||||
readonly persistGender: (gender: ProfileGender | null) => Promise<void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
async function choose(gender: ProfileGender | null) {
|
||||
setSaving(true);
|
||||
setNotice("");
|
||||
setError("");
|
||||
try {
|
||||
await persistGender(gender);
|
||||
setNotice("已保存。");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error && caught.message ? caught.message : "性别没保存上,再试一次。");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="sheet-section" aria-label="性别">
|
||||
<ProfileGenderField idPrefix="account-profile" value={value} disabled={saving} onChange={(gender) => void choose(gender)} />
|
||||
{notice ? <p className="form-success" role="status">{notice}</p> : null}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
selectedBirthPlace,
|
||||
} from "@/lib/home-profile";
|
||||
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
||||
import { withSavedGender, type ProfileGender } from "@/lib/profile-gender";
|
||||
import { createStartGreeting } from "@/lib/starter-greeting";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import {
|
||||
@@ -200,6 +201,26 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's optional gender (设置 → 个人资料). A PATCH of this one field:
|
||||
* birth-data saves never send it, so they can never clear it.
|
||||
*/
|
||||
async function persistGender(gender: ProfileGender | null): Promise<void> {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
const response = await fetch("/api/account", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ gender }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||
if (!response.ok) throw new Error(payload?.error || "性别没保存上,再试一次。");
|
||||
const apply = (current: Profile): Profile => ({ ...current, gender });
|
||||
setProfile(apply);
|
||||
setProfileDraft(apply);
|
||||
setAccount((current) => current ? { ...current, profile: withSavedGender(current.profile, gender) } : current);
|
||||
}
|
||||
|
||||
async function persistProfile(nextProfile: Profile): Promise<Profile> {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile;
|
||||
@@ -427,6 +448,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
persistProfile,
|
||||
assessSavedBirthTime,
|
||||
saveProfile,
|
||||
|
||||
@@ -35,3 +35,9 @@ export function profileGenderLabel(value: unknown): string {
|
||||
const gender = parseProfileGender(value);
|
||||
return gender ? PROFILE_GENDER_LABELS[gender] : PROFILE_GENDER_EMPTY_LABEL;
|
||||
}
|
||||
|
||||
/** The account's raw profile object with the saved gender (null = not filled). */
|
||||
export function withSavedGender(profile: unknown, gender: ProfileGender | null): Record<string, unknown> {
|
||||
const base = profile && typeof profile === "object" && !Array.isArray(profile) ? profile as Record<string, unknown> : {};
|
||||
return { ...base, gender };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user