fix(people): 本人 can edit their own birth data in 星盘档案 (S1, BUG-1081)

The owner had no edit entry anywhere once /people replaced the settings
chart pane. 本人 now gets 编辑 with the same form; the save goes through
PATCH /api/account via a shared lib/self-profile-save.ts that the home
onboarding also uses. The dead saveProfile is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 00:43:43 +08:00
co-authored by Claude Opus 5.5
parent adbb205440
commit 8415fdca72
18 changed files with 337 additions and 106 deletions
+15
View File
@@ -14547,3 +14547,18 @@
- 防复发:首页不得在「本人刚保存出生地」这一刻自动弹出任何弹窗;顶层 `<dialog>` 也要 portal 到 `SidebarInset` 之外;过场是否真的放了,部署后按 `docs/testing/birth-sky-cover-checklist.md` 新账号条目真机走一遍(若仍看不到过场,优先查 3 秒预算内 `/api/birth-sky` 的耗时)。
- 相关记录:BUG-968(`SidebarInset` inert 与 portal 约定)、BUG-434(兑换弹窗样式);`TASK-birth-sky-followup-20260928` F4、`TASK-birth-sky-polish-20260928` P4。
- 修复版本:分支 `codex/birth-sky-pacing-20260928`(本条所在提交),未部署。
## BUG-1081 | 全站没有任何地方能改本人的出生资料(星盘档案只给他人「编辑」)
- 状态:resolved(分支修复与回归测试已完成;真机待 staging 部署后按 `docs/testing/self-edit-avatar-menu-20260928.md` 走)
- 首次发现 / 最近更新:2026-09-28 / 2026-09-29
- 影响面:所有账户的本人出生资料(日期、时间与来源、地点、岁差、称呼);`/people` 星盘档案本人详情。
- 现象:产品在 staging 真机反馈「星盘档案里没法修改个人档案的信息」。本人详情只写「本人不能删除」,没有「编辑」;设置 → 个人资料只能改头像与性别;生时校正文案「请先到资料里改出生时间」指向一个不存在的入口。
- 触发条件:任何已完成建档的用户想改自己的出生资料。
- 根因:`ab6c55f8`(星盘档案 P1,2026-09-25)用 `/people` 取代设置弹窗里的星盘资料块,只给 `role === "other"` 做了编辑视图,`people-page.tsx` 注释写「本人 has no edit view here」;首页那块表单 `chart-library-panel.tsx` 已无引用,随 `d08ffdc0` 删除。首页 hook 的 `saveProfile` 从此没有任何 UI 调用。任务书 D1 写的是「统一列表、统一操作;户主不可删」,并没有「本人不可编辑」。
- 为什么没拦住:合同测试只断言「本人不能删除」与「他人有编辑」,没有任何测试断言「本人至少有一个出生资料编辑入口」;`settings-mvp-contract` 反而把「/people 不伪造已移除的本人编辑」写成了断言。
- 修复(`TASK-self-edit-avatar-menu-20260928` S1):本人详情也有「编辑」,编辑视图与他人同一张 `ChartProfileForm`(称呼、出生资料、岁差、性别),标题「编辑我的资料」,没有删除区。保存抽成 `lib/self-profile-save.ts`:出生资料走 `PATCH /api/account`(`selfProfilePatchBody`,带 `birth_time_source`、不带 `gender`),首页 onboarding 的 `persistProfile` 改用同一函数;性别变了再单独 PATCH `{ gender }`。保存后同步丢掉旧资料的副本:layout 的账户(`setAccount`)、首页 warm snapshot 与今日卡、星盘缓存(`pinChartSnapshotIdentity`)、星历缓存、人物目录。删除首页 hook 与 `page.tsx` 里无人调用的 `saveProfile`。
- 验证:`frontend/tests/self-edit-people-20260928.test.tsx`(本人详情有编辑、编辑视图无删除、PATCH 载荷带时间来源不带性别、失败透出路由文案、`/people` 本人保存不走 chart-profiles 且清理缓存、守卫:本人至少有一个编辑入口);改写断言见 `docs/tasks/PROGRESS-self-edit-avatar-menu-20260928.md`。
- 防复发:守卫测试锁住「本人出生资料至少有一个编辑入口,且走共享的 `saveSelfProfile`」;取代某个入口的改动必须在任务书里写明新入口。
- 相关记录:BUG-1030/1039(星盘档案)、BUG-018(账户出生资料声明与状态同写)。
- 修复版本:分支 `codex/self-edit-avatar-menu-20260928`,未部署。
+1 -1
View File
@@ -1010,7 +1010,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **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-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前,与个人资料同一个控件(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
+2 -1
View File
@@ -25,7 +25,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 性别(选填)(2026-09-27,TASK-consult-gender-optional)
- 标题「性别(选填)」,三个选项「女」「男」「不填」;下面只有一行说明:「用于婚恋解读里判断夫星 / 妻星,不填也能用。」不解释为什么只有两项,不劝用户填。
- 详情页没填写「未填」,不写「未知」「保密」。本人详情在值下面写「在「设置 → 个人资料」里改」。
- 详情页没填写「未填」,不写「未知」「保密」。本人与他人一样在星盘档案的「编辑」里改性别(2026-09-28,BUG-1081),详情页不再写「在「设置 → 个人资料」里改」。
- 星盘档案本人编辑:标题「编辑我的资料」,说明「称呼和出生资料。」;保存失败用服务端的句子,没有就写「保存失败,请重试」。
- 保存成功写「已保存。」;失败写「性别没保存上,再试一次。」(服务端有原因时用服务端的句子)。
- 注册 / 开场流程不问性别。回答里不说「你没填性别」来催填;数据卡写「性别未知」时,按精简清单两边都看。
+1 -3
View File
@@ -317,7 +317,6 @@ export default function Home() {
closeAccountDialog,
persistAvatar,
persistGender,
saveProfile,
saveOnboardingName,
saveOnboardingBirth,
saveOnboardingPlace,
@@ -328,9 +327,8 @@ export default function Home() {
birthTimeRevisionPendingRef, dialogReturnTargetRef,
setAccount, setAccountError, setAccountMenuOpen, setActiveAccountDialog,
setBirthTimeAssessmentPhase,
setBirthTimeConsultationConsent, setBirthTimeError, setBirthTimeJourney, setBillingPane, setDraft,
setBirthTimeError, setBirthTimeJourney, setBillingPane, setDraft,
setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength,
setRectificationError,
setSigningOut, setStartGreeting, signingOut, uiPreview,
initialProfile: () => warmStart()?.profile ?? null,
onOwnBirthPlaceSaved: birthSkyReveal.begin,
+1 -2
View File
@@ -1923,7 +1923,6 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4
.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); }
@@ -6087,7 +6086,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
cursor: pointer;
}
.people-archive-secondary:hover { background: var(--color-canvas-muted); }
.people-archive-foot { display: grid; text-align: center; }
.people-archive-foot { display: grid; gap: var(--space-2); justify-items: center; text-align: center; }
.people-archive-confirm {
display: grid;
gap: var(--space-3);
+54 -12
View File
@@ -9,6 +9,10 @@ import { ChartProfileForm } from "@/components/chart-profile-form";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { fetchAccount } from "@/lib/home-cloud-sync";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import { invalidateEphemerisPage, pinChartSnapshotIdentity } from "@/lib/secondary-page-data";
import { saveSelfGender, saveSelfProfile } from "@/lib/self-profile-save";
import { useSessionList } from "@/hooks/use-session-list";
import {
missingOtherProfileStep,
profileBirthTimeLabel,
@@ -41,8 +45,6 @@ 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)";
@@ -82,6 +84,8 @@ export function PeoplePage() {
const [pendingDelete, setPendingDelete] = useState<Usage | null>(null);
const [saving, setSaving] = useState(false);
const pushedDetail = useRef(false);
const accountIdRef = useRef("");
const { setAccount } = useSessionList();
async function load() {
setStatus("loading");
@@ -98,6 +102,7 @@ export function PeoplePage() {
const profile = readProfile(row.profile);
return [{ id: row.id, role: "other" as const, profile, incomplete: incompleteProfile(profile) }];
});
accountIdRef.current = account.user.id;
bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id));
setPeople([
{ id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) },
@@ -220,6 +225,42 @@ export function PeoplePage() {
}
}
/**
* 本人 (BUG-1081). The owner is never a `chart_profiles` row: birth data goes
* through the one account write (`PATCH /api/account`, `birth_time_source`
* checked there), gender through its own one-field PATCH. Then everything
* that painted the old birth data forgets it: the layout's account, Home's
* warm snapshot and today's card, the chart and ephemeris caches.
*/
async function saveSelf(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const current = people.find((person) => person.role === "self");
if (!current || missingOtherProfileStep(draft) || saving) return;
setSaving(true);
setNotice("");
try {
const saved = await saveSelfProfile(draft);
const gender = draft.gender ?? null;
if (gender !== (current.profile.gender ?? null)) await saveSelfGender(gender);
const profile: Profile = { ...saved.profile, gender };
if (saved.fingerprint) pinChartSnapshotIdentity({ accountId: accountIdRef.current, fingerprint: saved.fingerprint });
clearHomeWarmSnapshot();
invalidateEphemerisPage();
setAccount((account) => account ? {
...account,
profile,
...(saved.fingerprint ? { chartBirth: { fingerprint: saved.fingerprint } } : {}),
} : account);
setEditing(false);
invalidateSubjectCatalog();
await load();
} catch (caught) {
setNotice(caught instanceof Error && caught.message ? caught.message : "保存失败,请重试");
} finally {
setSaving(false);
}
}
async function askDelete() {
if (!selected || selected.role !== "other") return;
setPendingDelete(null);
@@ -285,6 +326,7 @@ export function PeoplePage() {
onCancelEdit={() => setEditing(false)}
onDraftChange={setDraft}
saveOther={saveOther}
saveSelf={saveSelf}
askDelete={askDelete}
onCancelDelete={() => setPendingDelete(null)}
confirmDelete={confirmDelete}
@@ -323,6 +365,7 @@ export type PeopleArchiveViewProps = {
readonly onCancelEdit: () => void;
readonly onDraftChange: Dispatch<SetStateAction<Profile>>;
readonly saveOther: (event: FormEvent<HTMLFormElement>) => Promise<void>;
readonly saveSelf: (event: FormEvent<HTMLFormElement>) => Promise<void>;
readonly askDelete: () => Promise<void>;
readonly onCancelDelete: () => void;
readonly confirmDelete: () => Promise<void>;
@@ -352,6 +395,7 @@ export function PeopleArchiveView({
onCancelEdit,
onDraftChange,
saveOther,
saveSelf,
askDelete,
onCancelDelete,
confirmDelete,
@@ -359,6 +403,7 @@ export function PeopleArchiveView({
}: PeopleArchiveViewProps) {
const remaining = MAX_SAVED_SUBJECTS - people.length;
const deleteNotice = notice === DELETE_USAGE_UNAVAILABLE || notice === DELETE_FAILED;
const editingSelf = editing && selectedId !== "" && selected?.role === "self";
return (
<div className="people-archive" data-view={view}>
<section className="people-archive-list" aria-label="星盘档案">
@@ -399,14 +444,14 @@ export function PeopleArchiveView({
</button>
{editing ? (
<ChartProfileForm
title={selectedId ? "编辑人物" : "添加一个人"}
description="称呼和出生资料。填报的出生时间保存后不能再改。"
title={editingSelf ? "编辑我的资料" : selectedId ? "编辑人物" : "添加一个人"}
description={editingSelf ? "称呼和出生资料。" : "称呼和出生资料。填报的出生时间保存后不能再改。"}
value={draft}
onChange={onDraftChange}
nameInputId="people-archive-name"
showAyanamsa
showGender
onSubmit={(event) => void saveOther(event)}
onSubmit={(event) => void (editingSelf ? saveSelf(event) : saveOther(event))}
onCancel={onCancelEdit}
submitLabel={saving ? "保存中" : "保存"}
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
@@ -438,10 +483,7 @@ export function PeopleArchiveView({
<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>
<dd>{profileGenderLabel(selected.profile.gender)}</dd>
</dl>
<div className="people-archive-actions">
<button type="button" onClick={() => openPerson(selected.id, newChatHref())}>
@@ -457,10 +499,10 @@ export function PeopleArchiveView({
{selected.role === "other" ? (
<button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
) : null}
{/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
<div className="people-archive-foot">
{selected.role === "self" ? <p>本人不能删除</p> : (
<button type="button" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</button>
)}
{selected.role === "self" ? <p>本人不能删除</p> : null}
<button type="button" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</button>
</div>
{notice ? <p className="form-error" role="alert">{notice}</p> : null}
</div>
+3 -73
View File
@@ -7,14 +7,9 @@ import {
type BeamAvatarPatch,
} from "@/lib/beam-avatar";
import {
applyPersistedBirthTime,
birthTimePersistenceValues,
isBirthTimeDraftReady,
} from "@/lib/birth-time-intake-model";
import {
birthTimeConsultationOptionsCopy,
createBirthTimeConsultationConsentState,
type BirthTimeConsultationConsentState,
type LatestAccountRequestGuard,
} from "@/lib/birth-time-consultation-consent";
import { readChartBirthFingerprint } from "@/lib/chart-birth-truth";
@@ -30,13 +25,12 @@ import {
pinChartSnapshotIdentity,
} from "@/lib/secondary-page-data";
import {
birthProfileDeclarationChanged,
missingProfileStep,
readProfile,
selectedBirthPlace,
} from "@/lib/home-profile";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import { withSavedGender, type ProfileGender } from "@/lib/profile-gender";
import { saveSelfProfile } from "@/lib/self-profile-save";
import { createStartGreeting } from "@/lib/starter-greeting";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
@@ -68,7 +62,6 @@ export type ProfileOnboardingParams = {
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
setActiveAccountDialog: Dispatch<SetStateAction<AccountDialog | null>>;
setBirthTimeAssessmentPhase: Dispatch<SetStateAction<BirthTimeAssessmentPhase | null>>;
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
setBirthTimeError: Dispatch<SetStateAction<string>>;
setBirthTimeJourney: Dispatch<SetStateAction<JourneyClientResponse | null>>;
setBillingPane: Dispatch<SetStateAction<OpenAccountDialogOptions>>;
@@ -76,7 +69,6 @@ export type ProfileOnboardingParams = {
setOnboardingJustCompleted: Dispatch<SetStateAction<boolean>>;
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
setPresetMessageLength: Dispatch<SetStateAction<number>>;
setRectificationError: Dispatch<SetStateAction<string>>;
setSigningOut: Dispatch<SetStateAction<boolean>>;
setStartGreeting: Dispatch<SetStateAction<string>>;
signingOut: boolean;
@@ -99,7 +91,6 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setAccountMenuOpen,
setActiveAccountDialog,
setBirthTimeAssessmentPhase,
setBirthTimeConsultationConsent,
setBirthTimeError,
setBirthTimeJourney,
setBillingPane,
@@ -107,7 +98,6 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setOnboardingJustCompleted,
setOnboardingStep,
setPresetMessageLength,
setRectificationError,
setSigningOut,
setStartGreeting,
signingOut,
@@ -227,42 +217,9 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
async function persistProfile(nextProfile: Profile): Promise<Profile> {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile;
const birthPlace = selectedBirthPlace(nextProfile);
const response = await fetch("/api/account", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
...birthTimePersistenceValues(nextProfile),
country_code: nextProfile.countryCode,
province_code: nextProfile.provinceCode || null,
city_code: nextProfile.cityCode || null,
district_code: nextProfile.districtCode || null,
birth_place_label: nextProfile.birthPlaceLabel || null,
birth_place_type: nextProfile.birthPlaceType || null,
birth_place_provider: nextProfile.birthPlaceProvider || null,
birth_place_provider_id: nextProfile.birthPlaceProviderId || null,
latitude: birthPlace?.lat ?? null,
longitude: birthPlace?.lon ?? null,
timezone_id: nextProfile.timezoneId || null,
timezone_offset: birthPlace?.tz ?? null,
timezone_source: nextProfile.timezoneSource || null,
ayanamsa: resolveAyanamsa(nextProfile),
}),
});
const payload = await response.json().catch(() => null) as {
error?: string;
birthTime?: unknown;
chartBirth?: { fingerprint?: unknown };
} | null;
if (!response.ok) {
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
const fingerprint = readChartBirthFingerprint(payload);
// One write path for the owner's birth data, shared with 星盘档案 (BUG-1081).
const { profile: savedProfile, fingerprint } = await saveSelfProfile(nextProfile);
if (fingerprint) pinChartSnapshotIdentity({ accountId: account.user.id, fingerprint });
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
return savedProfile;
}
@@ -290,32 +247,6 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
return assessedProfile;
}
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setProfileNotice("");
setAccountError("");
try {
const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft);
const savedProfile = await persistProfile(profileDraft);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setRectificationError("");
if (declarationChanged) {
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
void refreshAccount();
}
setProfileNotice(savedProfile.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
setProfileSaving(false);
}
}
async function saveOnboardingName() {
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
if (!name || !account || profileSaving) return;
@@ -456,7 +387,6 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
persistGender,
persistProfile,
assessSavedBirthTime,
saveProfile,
saveOnboardingName,
saveOnboardingBirth,
editDeclaredBirthTimeDetails,
+80
View File
@@ -0,0 +1,80 @@
import { resolveAyanamsa } from "./ayanamsa.ts";
import { applyPersistedBirthTime, birthTimePersistenceValues } from "./birth-time-intake-model.ts";
import { readChartBirthFingerprint } from "./chart-birth-truth.ts";
import { selectedBirthPlace } from "./home-profile.ts";
import type { Profile } from "./home-types.ts";
import type { ProfileGender } from "./profile-gender.ts";
/**
* The owner's own profile write (`PATCH /api/account`), shared by the home
* onboarding and the 星盘档案 edit view (TASK-self-edit-avatar-menu-20260928
* S1, BUG-1081). The owner is never a `chart_profiles` row; every birth-data
* save goes through the account route, which checks `birth_time_source` and
* derives the birth-time status on the server.
*
* Birth-data saves never carry `gender` (TASK-consult-gender-optional): the
* owner's gender is its own one-field PATCH, so a birth-data save can never
* clear it.
*/
export function selfProfilePatchBody(nextProfile: Profile) {
const birthPlace = selectedBirthPlace(nextProfile);
return {
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
...birthTimePersistenceValues(nextProfile),
country_code: nextProfile.countryCode,
province_code: nextProfile.provinceCode || null,
city_code: nextProfile.cityCode || null,
district_code: nextProfile.districtCode || null,
birth_place_label: nextProfile.birthPlaceLabel || null,
birth_place_type: nextProfile.birthPlaceType || null,
birth_place_provider: nextProfile.birthPlaceProvider || null,
birth_place_provider_id: nextProfile.birthPlaceProviderId || null,
latitude: birthPlace?.lat ?? null,
longitude: birthPlace?.lon ?? null,
timezone_id: nextProfile.timezoneId || null,
timezone_offset: birthPlace?.tz ?? null,
timezone_source: nextProfile.timezoneSource || null,
ayanamsa: resolveAyanamsa(nextProfile),
};
}
export type SelfProfileSaved = Readonly<{
/** The draft with the birth-time truth the server returned applied. */
profile: Profile;
/** The chart page's `profileFingerprint` of the saved row, when the route returned one. */
fingerprint: string | null;
}>;
type Fetch = typeof fetch;
export async function saveSelfProfile(nextProfile: Profile, fetchImpl: Fetch = fetch): Promise<SelfProfileSaved> {
const response = await fetchImpl("/api/account", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(selfProfilePatchBody(nextProfile)),
});
const payload = await response.json().catch(() => null) as {
error?: string;
birthTime?: unknown;
chartBirth?: { fingerprint?: unknown };
} | null;
if (!response.ok) {
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
return { profile: savedProfile, fingerprint: readChartBirthFingerprint(payload) };
}
/** The owner's optional gender, on its own; null clears it. */
export async function saveSelfGender(gender: ProfileGender | null, fetchImpl: Fetch = fetch): Promise<void> {
const response = await fetchImpl("/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 || "性别没保存上,再试一次。");
}
@@ -231,7 +231,10 @@ test("homepage and profile result copy use the source-aware consultation options
const page = homeSurface;
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/);
// 原值: assert.match(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/)——首页 hook 的 `saveProfile` 保存后提示用这句
// 新值: 首页不再有这条保存提示(doesNotMatch);来源感知的选项文案仍由录入表单 `birth-time-intake` 给出(下方断言不动)
// 原因: BUG-1081——`saveProfile` 自 `ab6c55f8` 起已无 UI 调用,随 TASK-self-edit-avatar-menu-20260928 S1 删除;星盘档案本人保存后直接回到已更新的详情
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(savedProfile\)/);
assert.doesNotMatch(page, /birthTimeConsultationOptionsCopy\(profile\)/);
assert.match(intake, /birthTimeConsultationOptionsCopy\(value\)/);
});
@@ -151,7 +151,10 @@ test("terminal CJK copy stays intact while homepage candidates remain unconfirme
const pageSource = homeSurface;
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/);
// 原值: assert.match(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/)
// 新值: 首页不再有这条保存提示(doesNotMatch);本条要守的「候选未确认时不写成当前排盘时间」由其余断言继续锁住
// 原因: BUG-1081——这句只在无 UI 调用的 `saveProfile` 里,随 TASK-self-edit-avatar-menu-20260928 S1 删除
assert.doesNotMatch(pageSource, /`出生资料已保存。\$\{birthTimeConsultationOptionsCopy\(savedProfile\)\}`/);
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
@@ -341,10 +341,16 @@ test("session changes contain no birth-time notice state", () => {
test("profile and place saves do not auto-start the retired assessment flow", () => {
const source = homeSurface;
const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName"));
const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime"));
assert.doesNotMatch(normalSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
// 原值: 从首页 hook 切出 `saveProfile` 断言它不自动评估
// 新值: 首页 hook 不再有 `saveProfile`;本人资料编辑的唯一入口 `saveSelf`(星盘档案)同样不评估
// 原因: BUG-1081——`saveProfile` 早已无 UI 调用,随 S1 删除;编辑入口搬到 /people
assert.doesNotMatch(source, /async function saveProfile/);
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const selfSave = people.slice(people.indexOf("async function saveSelf"), people.indexOf("async function askDelete"));
assert.ok(selfSave.length > 0);
assert.doesNotMatch(selfSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
assert.doesNotMatch(placeSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
});
+1
View File
@@ -28,6 +28,7 @@ const optionalHomeHookFiles = [
"../src/lib/session-list-filter.ts",
"../src/lib/home-bootstrap-run.ts",
"../src/lib/consultation-recovery-poll.ts",
"../src/lib/self-profile-save.ts",
] as const;
export const homeSurface = [
@@ -81,7 +81,10 @@ test("people delete requires known usage and synastry never switches the global
assert.match(people, /暂时查不到这个人的对话数量,稍后再试/);
assert.match(people, /Number\.isInteger\(usage\.sessions\)/);
assert.match(people, /pendingDelete\?\.id !== selected\.id/);
assert.match(people, /title=\{selectedId \? "编辑人物" : "添加一个人"\}/);
// 原值: /title=\{selectedId \? "编辑人物" : "添加一个人"\}/
// 新值: 同一张表多一种标题:本人「编辑我的资料」,其余不变
// 原因: BUG-1081——本人在星盘档案可编辑(TASK-self-edit-avatar-menu-20260928 S1)
assert.match(people, /title=\{editingSelf \? "编辑我的资料" : selectedId \? "编辑人物" : "添加一个人"\}/);
assert.doesNotMatch(people, /openPerson\(selected\.id,.*synastry/);
assert.match(source("hooks/use-synastry.ts"), /startNewChat\(\{ subjectId: "self" \}\)/);
});
@@ -80,6 +80,7 @@ function props(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchiveVi
onCancelEdit: noop,
onDraftChange: noop,
saveOther: asyncNoop,
saveSelf: asyncNoop,
askDelete: asyncNoop,
onCancelDelete: noop,
confirmDelete: asyncNoop,
@@ -46,7 +46,7 @@ function viewProps(overrides: Partial<PeopleArchiveViewProps> = {}): PeopleArchi
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,
saveOther: asyncNoop, saveSelf: asyncNoop, askDelete: asyncNoop, onCancelDelete: noop, confirmDelete: asyncNoop, openPerson: noop,
...overrides,
};
}
@@ -111,12 +111,17 @@ test("people edit form: the gender field is part of the form and writes the draf
assert.deepEqual(radios(editing), ["女:false", "男:true", "不填:false"]);
});
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 points to 设置 → 个人资料", () => {
test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edits it in the same form as everyone", () => {
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");
// 原值: /<dd>未填<small class="people-archive-fact-note">在「设置 → 个人资料」里改<\/small><\/dd>/,且本页没有本人的性别单选
// 新值: /<dd>未填<\/dd>/;本人的编辑视图里有性别单选(与他人同一张表)
// 原因: BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1——本人在星盘档案可编辑,小字指向的「设置里改」不再是唯一入口
assert.match(self, /<dt>性别<\/dt><dd>未填<\/dd>/);
assert.equal(self.includes("role=\"radio\""), false, "the detail card itself has no radios");
const editing = renderToStaticMarkup(<PeopleArchiveView {...viewProps({ editing: true, draft: selfProfile })} />);
assert.equal(editing.includes("role=\"radio\""), true, "the owner's edit form carries gender like everyone's");
});
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
@@ -137,7 +142,7 @@ test("个人资料: the owner's gender saves on its own and birth-data saves nev
});
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"]) {
for (const name of ["profile-gender-field", "profile-gender-title", "profile-gender-options", "profile-gender-option", "profile-gender-hint"]) {
assert.match(css, new RegExp(`\\.${name}(?![a-z-])[^{]*\\{`), name);
}
assert.match(css, /\.profile-gender-option \{[^}]*min-height: 44px/);
@@ -118,9 +118,11 @@ test("only the reader's own onboarding place save triggers it, after the flow ha
assert.ok(persisted > 0 && nextStep > persisted && trigger > nextStep, "trigger runs after the save succeeded and the next step is set");
assert.ok(trigger < place.indexOf("catch (caught)"), "a failed save never triggers it");
assert.equal(onboarding.match(/onOwnBirthPlaceSaved\?\.\(\)/g)?.length, 1);
// Profile edits (saveProfile) and the birth-time step do not trigger it.
const saveProfile = onboarding.slice(onboarding.indexOf("async function saveProfile"), onboarding.indexOf("async function saveOnboardingName"));
assert.doesNotMatch(saveProfile, /onOwnBirthPlaceSaved/);
// Profile edits and the birth-time step do not trigger it.
// 原值: 切出首页 hook 的 `saveProfile` 断言不含 onOwnBirthPlaceSaved
// 新值: 首页 hook 不再有 `saveProfile`;本人编辑在 people-page(下方循环已断言它不触发)
// 原因: BUG-1081——无 UI 调用的 `saveProfile` 删除,本人编辑入口在星盘档案
assert.doesNotMatch(onboarding, /async function saveProfile/);
// New family members in the people archive never reach this hook.
for (const path of [
"../src/components/chart-profile-form.tsx",
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { PeopleArchiveView, type PeopleArchiveViewProps, type PersonRow } from "../src/components/people/people-page.tsx";
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
import { saveSelfGender, saveSelfProfile, selfProfilePatchBody } from "../src/lib/self-profile-save.ts";
Object.assign(globalThis, { React });
/*
* BUG-1081 / TASK-self-edit-avatar-menu-20260928 S1: the owner's birth data
* had no edit entry anywhere after /people replaced the settings chart pane.
* Fictional people only (AGENTS §8).
*/
const peopleSource = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const onboardingSource = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
const selfProfile: Profile = {
...emptyProfile,
name: "示例甲",
date: "1994-05-18",
time: "07:40",
reportedTime: "07:40",
birthTimeSource: "family_exact",
birthTimeStatus: "accepted",
countryCode: "CN",
birthPlaceLabel: "中国 · 浙江省 · 杭州市 · 西湖区",
latitude: 30.27,
longitude: 120.15,
timezoneId: "Asia/Shanghai",
timezoneOffset: 8,
timezoneSource: "iana",
ayanamsa: "raman",
gender: "female",
};
const otherProfile: Profile = { ...selfProfile, name: "示例乙", gender: null };
const people: PersonRow[] = [
{ id: "self", role: "self", profile: selfProfile, incomplete: false },
{ id: "11111111-1111-4111-8111-111111111111", role: "other", profile: otherProfile, incomplete: false },
];
function render(overrides: Partial<PeopleArchiveViewProps> = {}) {
const noop = () => {};
const asyncNoop = async () => {};
const props: PeopleArchiveViewProps = {
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, saveSelf: asyncNoop, askDelete: asyncNoop, onCancelDelete: noop, confirmDelete: asyncNoop, openPerson: noop,
...overrides,
};
return renderToStaticMarkup(React.createElement(PeopleArchiveView, props));
}
type Call = { url: string; init: RequestInit };
function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
return (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}) as typeof fetch;
}
test("本人 detail offers 编辑 and still says it cannot be deleted", () => {
const detail = render();
assert.match(detail, /class="people-archive-foot"><p>本人不能删除<\/p><button type="button" class="people-archive-secondary">编辑<\/button>/);
assert.doesNotMatch(detail, /删除这个人|确认删除/);
});
test("本人 edit view: its own title, gender like everyone, and no delete area", () => {
const editing = render({ editing: true, draft: selfProfile });
assert.match(editing, /编辑我的资料/);
assert.doesNotMatch(editing, /编辑人物|填报的出生时间保存后不能再改/);
assert.equal(editing.includes("role=\"radio\""), true);
assert.doesNotMatch(editing, /people-archive-danger|删除这个人/);
const other = render({ editing: true, selected: people[1]!, selectedId: people[1]!.id, draft: otherProfile });
assert.match(other, /编辑人物/);
assert.match(other, /删除这个人/);
});
test("the owner's birth data goes to PATCH /api/account with its time source and never with gender", async () => {
const body = selfProfilePatchBody(selfProfile);
assert.equal(body.birth_time_source, "family_exact");
assert.equal(body.reported_birth_time, "07:40");
assert.equal(body.birth_place_label, "中国 · 浙江省 · 杭州市 · 西湖区");
assert.equal("gender" in body, false, "birth-data saves never carry gender");
const calls: Call[] = [];
const fingerprint = "0123456789abcdef0123456789abcdef";
const saved = await saveSelfProfile(selfProfile, fakeFetch(200, {
birthTime: { status: "accepted", activeTime: "07:40" },
chartBirth: { fingerprint },
}, calls));
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, "/api/account");
assert.equal(calls[0]!.init.method, "PATCH");
assert.equal(JSON.parse(String(calls[0]!.init.body)).birth_time_source, "family_exact");
assert.equal(saved.fingerprint, fingerprint);
assert.equal(saved.profile.name, "示例甲");
});
test("a refused save surfaces the route's message; gender is its own one-field PATCH", async () => {
await assert.rejects(
saveSelfProfile(selfProfile, fakeFetch(400, { error: "修改出生资料时必须同时说明时间来源" }, [])),
/修改出生资料时必须同时说明时间来源/,
);
await assert.rejects(saveSelfProfile(selfProfile, fakeFetch(500, null, [])), /账户资料暂时无法保存。/);
const calls: Call[] = [];
await saveSelfGender("male", fakeFetch(200, {}, calls));
assert.deepEqual(JSON.parse(String(calls[0]!.init.body)), { gender: "male" });
});
test("星盘档案 saves 本人 through the account write, not chart_profiles, and drops every stale copy", () => {
const selfSave = peopleSource.slice(peopleSource.indexOf("async function saveSelf"), peopleSource.indexOf("async function askDelete"));
assert.ok(selfSave.length > 0);
assert.match(selfSave, /await saveSelfProfile\(draft\)/);
assert.match(selfSave, /saveSelfGender\(gender\)/);
assert.doesNotMatch(selfSave, /chart-profiles|role: "self"/);
assert.match(selfSave, /pinChartSnapshotIdentity\(/);
assert.match(selfSave, /clearHomeWarmSnapshot\(\)/);
assert.match(selfSave, /invalidateEphemerisPage\(\)/);
assert.match(selfSave, /setAccount\(/);
assert.match(selfSave, /invalidateSubjectCatalog\(\)/);
assert.match(selfSave, /setNotice\(caught instanceof Error/);
});
test("guard: the product keeps at least one edit entry for the owner's birth data (BUG-1081)", () => {
// Home's `saveProfile` had no caller left once /people replaced the settings
// pane, and nothing noticed. The entry now lives on /people, on the same
// shared write the home onboarding uses.
assert.match(peopleSource, /onClick=\{\(\) => onEdit\(selected\.profile\)\}>编辑</);
assert.doesNotMatch(peopleSource, /selected\.role === "self" \? <p>本人不能删除<\/p> : \(/);
assert.match(onboardingSource, /await saveSelfProfile\(nextProfile\)/);
assert.doesNotMatch(onboardingSource, /async function saveProfile/);
});
+5 -1
View File
@@ -31,7 +31,11 @@ test("the chart library owns self-profile editing and keeps other-chart save sep
// 原值 / 新值 / 原因:已退役面板编辑本人 / 本人账户来源、独立他人保存 / /people 是唯一入口,不伪造已移除的本人编辑。
assert.match(charts, /readProfile\(account\.profile\)/);
assert.match(charts, /selectedId && selected\?\.role === "other"/);
assert.match(charts, /onSubmit=\{\(event\) => void saveOther\(event\)\}/);
// 原值: /onSubmit=\{\(event\) => void saveOther\(event\)\}/
// 新值: 同一张表按 editingSelf 分给 saveSelf / saveOther
// 原因: BUG-1081——本人在星盘档案可编辑,保存走 PATCH /api/account,他人仍走 chart-profiles
assert.match(charts, /onSubmit=\{\(event\) => void \(editingSelf \? saveSelf\(event\) : saveOther\(event\)\)\}/);
assert.match(charts, /await saveSelfProfile\(draft\)/);
assert.doesNotMatch(charts, /setEditingSelfChart/);
assert.match(form, /AyanamsaPreferenceField/);
assert.doesNotMatch(personalProfile, /AyanamsaPreferenceField/);