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:
Jesse_Chen
2026-09-27 18:30:58 +08:00
co-authored by Claude Opus 5.5
parent 5caf03844e
commit d79dada970
19 changed files with 318 additions and 3 deletions
@@ -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>
);
}
+41
View File
@@ -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>
);
}