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
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -655,7 +655,7 @@ page. Three parts now, in reading order:
|
||||
- **Width / height:** desktop `width: min(100vw - 32px, 880px)`, with `height: min(84vh, 640px)` as the base and `min(84dvh, 640px)` applied inside `@supports (height: 1dvh)`. The right content pane uses 32px horizontal and bottom inset on desktop. All four panes share one class (`.settings-modal`), so switching panes cannot change the frame. At ≤767px the dialog is full-screen with four equal tabs along the top and 16px content inset.
|
||||
- **Pane menu states:** default is transparent with secondary ink; hover is a 55% wash of `--color-canvas-muted` keeping secondary ink; current is the solid muted surface with primary ink. No accent bar, no trailing navigation chevron, and no weight change — hierarchy here comes from ink rank and surface, matching “Hierarchy inside the nav comes from ink rank, not hue”. The sidebar's 2px `--sidebar-ring` on the active session is deliberately **not** changed to match; the two surfaces read differently on purpose until that is revisited.
|
||||
- **Content width:** the 880px frame now gives the content pane a deliberate inset instead of leaving controls against the divider. Form panes (个人资料, 通用设置) cap their children at 560px and stay left aligned (`.settings-dialog-content--form > *`); list panes (星盘资料, 账户与点数) stay full-bleed within the inset so tables and card grids keep their columns. The cap sits on the children of the scroll container only — never on `.settings-modal` or `.account-settings-shell` — so it cannot make the frame resize between panes (BUG-554 / BUG-698).
|
||||
- **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button.
|
||||
- **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. Below them sits **性别(选填)** (2026-09-27, TASK-consult-gender-optional): three 44px choices 女 / 男 / 不填 in one row (the birth-time range chip look: `--color-border`, selected `--color-action` border + `--color-action-soft`), then one caption line 「用于婚恋解读里判断夫星 / 妻星,不填也能用。」. A choice saves at once (like the avatar palette) and shows 「已保存。」; the choices are disabled while saving, no spinner. This is the only place the owner sets it; onboarding never asks. The header uses the product-level “设置” eyebrow above the current pane title; there is no duplicate 管理星盘资料 button.
|
||||
- **Chart archive (`/people`):** a static secondary page beside 星盘 / 星历 / 我的报告. Self is the first row and cannot be deleted. Each other row is a name plus birth facts, with no relationship field. Detail shows the reported time and the chart time separately, then place and ayanamsa. Shortcuts are 和 TA 对话, 看星盘, 生成报告, and 和我合盘. Adding a sixth person is disabled with 「最多保存 5 个人的星盘」. Delete confirms with the session and report counts before the server removes them together. There is no 「设为默认」 and no 「当前默认」 badge.
|
||||
- **Subject switcher:** the same control sits beside the title on chat, chart, ephemeris, reports, and the archive. It is not in the sidebar header. Choosing someone in a chat that already has messages switches the current person and opens an empty chat. An empty chat rebinds in place. The list shows at most five people.
|
||||
- **States:** open, pane switch, list / self / other / add, saving, success, and error.
|
||||
@@ -972,7 +972,8 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
- **1024px 及以上:两栏。** 左列表(18–22rem),右详情;「‹ 返回」隐藏。右栏正在看的那一行用中性浅底(`is-open`),强调色只给当前人物。
|
||||
- **列表行**(`.people-archive-row`,最小 64px):40px 圆形首字头像(本人写「我」)+ 名字(本人后跟灰底「本人」标签)+ 一行等宽 `tabular-nums` 的「出生日期 · 排盘时间 · 城市」+ 行尾「›」。资料不完整时这一行写「这份资料还不完整」。**当前人物**(`is-current`,与顶栏「当前星盘」同一个人)是 `--color-action-strong` 描边 + `--color-action-soft` 底。
|
||||
- **添加**:虚线卡片「+ 添加一个人」(48px,`--color-border-strong` 虚线,文字 `--color-action`),下面小字「还可以保存 N 个人」,N 仍是 `MAX_SAVED_SUBJECTS - 人数`;到顶时换成原来的限额句并禁用。
|
||||
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期与岁差(显示「Raman(默认)」这类设置里的名字,不露小写键),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」与「编辑」;本人底部灰字「本人不能删除」。
|
||||
- **详情卡**:头部 48px 头像 + 名字 +「本人 · 出生地」(他人只写出生地);两格时间卡「填报时间」「排盘时间」,等宽大号数字。只有采用或确认了**校正结果**(状态 accepted / confirmed **且**有校正案例)时,排盘卡换成 `--color-success` 描边 + `--color-success-muted` 底,标签写「排盘时间 · 已校正」——旧资料读回时的 `confirmed` 兜底不算。下面是出生日期、岁差(显示「Raman(默认)」这类设置里的名字,不露小写键)与性别(「女」「男」,没填写「未填」;本人在值下面加一行灰色小字「在「设置 → 个人资料」里改」,这一页不给本人第二个性别入口),再下面三个等宽快捷入口「和 TA 对话 / 看星盘 / 生成报告」(图标用侧栏同款 lucide)。他人另有「和我合盘」与「编辑」;本人底部灰字「本人不能删除」。
|
||||
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前,与个人资料同一个控件(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。
|
||||
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
|
||||
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
|
||||
|
||||
## 性别(选填)(2026-09-27,TASK-consult-gender-optional)
|
||||
|
||||
- 标题「性别(选填)」,三个选项「女」「男」「不填」;下面只有一行说明:「用于婚恋解读里判断夫星 / 妻星,不填也能用。」不解释为什么只有两项,不劝用户填。
|
||||
- 详情页没填写「未填」,不写「未知」「保密」。本人详情在值下面写「在「设置 → 个人资料」里改」。
|
||||
- 保存成功写「已保存。」;失败写「性别没保存上,再试一次。」(服务端有原因时用服务端的句子)。
|
||||
- 注册 / 开场流程不问性别。回答里不说「你没填性别」来催填;数据卡写「性别未知」时,按精简清单两边都看。
|
||||
|
||||
## 生时校正:范围、时刻、吻合率由服务端说(2026-09-27,BUG-1055~1058)
|
||||
|
||||
证据轮与交付轮里,范围、代表分钟和吻合率只由服务端说。服务端事实句照原样接在模型正文后面,不参与裁句:
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// TASK-consult-gender-optional-20260927 T2: optional gender radio (女 / 男 / 不填)
|
||||
// in the people-archive edit form and the owner's 个人资料, one explanatory
|
||||
// line, and the detail card shows the stored value (未填 when empty).
|
||||
// Fictional people only (AGENTS §8).
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React, { type ReactElement, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ChartProfileForm, type ChartProfileFormProps } from "../src/components/chart-profile-form.tsx";
|
||||
import { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
|
||||
import { ProfileGenderField } from "../src/components/profile-gender-field.tsx";
|
||||
import { ProfilePanel } from "../src/components/profile-panel.tsx";
|
||||
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
|
||||
import { PROFILE_GENDER_HINT, type ProfileGender } from "../src/lib/profile-gender.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
const selfProfile: Profile = {
|
||||
...emptyProfile,
|
||||
name: "示例甲",
|
||||
date: "1990-01-02",
|
||||
reportedTime: "05:00",
|
||||
time: "05:00",
|
||||
birthTimeSource: "family_exact",
|
||||
birthTimeStatus: "reported",
|
||||
birthPlaceLabel: "中国 · 浙江省 · 杭州市",
|
||||
latitude: 30.25,
|
||||
longitude: 120.13,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
const otherProfile: Profile = { ...selfProfile, name: "示例乙", gender: "male" };
|
||||
const people: PersonRow[] = [
|
||||
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
|
||||
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
|
||||
];
|
||||
|
||||
function viewProps(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveViewProps {
|
||||
const noop = () => {};
|
||||
const asyncNoop = async () => {};
|
||||
return {
|
||||
people, subjectId: "self", selected: people[0]!, selectedId: "self", view: "detail", editing: false,
|
||||
draft: emptyProfile, notice: "", pendingDelete: null, saving: false, atLimit: false,
|
||||
onSelect: noop, onBack: noop, onAdd: noop, onEdit: noop, onCancelEdit: noop, onDraftChange: noop,
|
||||
saveOther: asyncNoop, askDelete: asyncNoop, onCancelDelete: noop, confirmDelete: asyncNoop, openPerson: noop,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** All elements in a hook-free component's rendered tree (function components are expanded). */
|
||||
function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!React.isValidElement(node)) return [];
|
||||
const element = node as ReactElement<Record<string, unknown>>;
|
||||
const out = [element];
|
||||
if (typeof element.type === "function" && (element.type === ProfileGenderField)) {
|
||||
out.push(...elements((element.type as (props: unknown) => ReactNode)(element.props)));
|
||||
}
|
||||
out.push(...elements(element.props.children as ReactNode));
|
||||
return out;
|
||||
}
|
||||
|
||||
function radios(markup: string) {
|
||||
return [...markup.matchAll(/<button[^>]*role="radio"[^>]*aria-checked="(true|false)"[^>]*>([^<]*)<\/button>/g)]
|
||||
.map((match) => `${match[2]}:${match[1]}`);
|
||||
}
|
||||
|
||||
test("the field: 女 / 男 / 不填 as one radio group, 不填 selected when nothing is stored, one hint line", () => {
|
||||
const empty = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value={undefined} onChange={() => {}} />);
|
||||
assert.deepEqual(radios(empty), ["女:false", "男:false", "不填:true"]);
|
||||
assert.match(empty, /role="radiogroup" aria-labelledby="t-gender-title" aria-describedby="t-gender-hint"/);
|
||||
assert.match(empty, /性别(选填)/);
|
||||
assert.ok(empty.includes(PROFILE_GENDER_HINT));
|
||||
assert.equal(PROFILE_GENDER_HINT, "用于婚恋解读里判断夫星 / 妻星,不填也能用。");
|
||||
const female = renderToStaticMarkup(<ProfileGenderField idPrefix="t" value="female" onChange={() => {}} />);
|
||||
assert.deepEqual(radios(female), ["女:true", "男:false", "不填:false"]);
|
||||
|
||||
const chosen: Array<ProfileGender | null> = [];
|
||||
const tree = elements(ProfileGenderField({ idPrefix: "t", value: "female", onChange: (value) => chosen.push(value) }));
|
||||
const buttons = tree.filter((element) => element.type === "button");
|
||||
for (const button of buttons) (button.props.onClick as () => void)();
|
||||
assert.deepEqual(chosen, ["male", null], "clicking the selected choice again is a no-op; 不填 stores null");
|
||||
});
|
||||
|
||||
test("people edit form: the gender field is part of the form and writes the draft (不填 → null)", () => {
|
||||
let draft: Profile = { ...otherProfile, gender: "male" };
|
||||
const props: ChartProfileFormProps = {
|
||||
title: "编辑人物", description: "", value: draft, nameInputId: "people-archive-name", showAyanamsa: true,
|
||||
showGender: true, submitLabel: "保存", submitDisabled: false, onSubmit: () => {},
|
||||
onChange: (next) => { draft = typeof next === "function" ? next(draft) : next; },
|
||||
};
|
||||
const markup = renderToStaticMarkup(<ChartProfileForm {...props} />);
|
||||
assert.deepEqual(radios(markup), ["女:false", "男:true", "不填:false"]);
|
||||
const field = elements(ChartProfileForm(props)).find((element) => element.type === ProfileGenderField);
|
||||
assert.ok(field);
|
||||
(field.props.onChange as (value: ProfileGender | null) => void)(null);
|
||||
assert.equal(draft.gender, null, "explicit null so the PUT clears the stored value");
|
||||
(field.props.onChange as (value: ProfileGender | null) => void)("female");
|
||||
assert.equal(draft.gender, "female");
|
||||
assert.equal(draft.name, "示例乙", "other fields untouched");
|
||||
const without = renderToStaticMarkup(<ChartProfileForm {...props} showGender={false} />);
|
||||
assert.equal(without.includes("性别(选填)"), false);
|
||||
|
||||
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({
|
||||
selected: people[1]!, selectedId: people[1]!.id, editing: true, draft: otherProfile,
|
||||
})} />);
|
||||
assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]);
|
||||
});
|
||||
|
||||
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 points to 设置 → 个人资料", () => {
|
||||
const other = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ selected: people[1]!, selectedId: people[1]!.id })} />);
|
||||
assert.match(other, /<dt>性别<\/dt><dd>男<\/dd>/);
|
||||
const self = renderToStaticMarkup(<PeopleArchiveView {...viewProps()} />);
|
||||
assert.match(self, /<dt>性别<\/dt><dd>未填<small class="people-archive-fact-note">在「设置 → 个人资料」里改<\/small><\/dd>/);
|
||||
assert.equal(self.includes("role=\"radio\""), false, "no second entry for the owner on this page");
|
||||
});
|
||||
|
||||
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
|
||||
const account = { user: { id: "u", email: "fictional@example.invalid" }, avatar: null } as unknown as Account;
|
||||
const panel = renderToStaticMarkup(
|
||||
<ProfilePanel account={account} profile={{ ...selfProfile, gender: "female" }} avatarSaving={false} avatarNotice=""
|
||||
accountError="" persistAvatar={() => {}} persistGender={async () => {}} />,
|
||||
);
|
||||
assert.deepEqual(radios(panel), ["女:true", "男:false", "不填:false"]);
|
||||
assert.ok(panel.includes(PROFILE_GENDER_HINT));
|
||||
const hook = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
|
||||
const persistGender = hook.slice(hook.indexOf("async function persistGender"), hook.indexOf("async function persistProfile"));
|
||||
assert.match(persistGender, /body: JSON\.stringify\(\{ gender \}\)/);
|
||||
const persistProfile = hook.slice(hook.indexOf("async function persistProfile"), hook.indexOf("async function assessSavedBirthTime"));
|
||||
assert.equal(/gender/.test(persistProfile), false, "onboarding and birth-data saves do not carry gender");
|
||||
const onboarding = readFileSync(new URL("../src/components/home-onboarding-shell.tsx", import.meta.url), "utf8");
|
||||
assert.equal(/ProfileGenderField|性别/.test(onboarding), false, "not asked in onboarding (G1)");
|
||||
});
|
||||
|
||||
test("CSS: the choices are 44px touch targets and every new class has a rule", () => {
|
||||
for (const name of ["profile-gender-field", "profile-gender-title", "profile-gender-options", "profile-gender-option", "profile-gender-hint", "people-archive-fact-note"]) {
|
||||
assert.match(css, new RegExp(`\\.${name}(?![a-z-])[^{]*\\{`), name);
|
||||
}
|
||||
assert.match(css, /\.profile-gender-option \{[^}]*min-height: 44px/);
|
||||
assert.match(css, /\.profile-gender-option\.is-selected \{[^}]*var\(--color-action-soft\)/);
|
||||
});
|
||||