fix(settings): gender is set only in the people archive; drop the 个人资料 duplicate (BUG-1119)
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:
co-authored by
Claude Opus 5.5
parent
3177ffe61d
commit
3e29b697db
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-30 — 性别只在星盘档案里设置
|
||||
|
||||
- 设置 →「个人资料」里的性别选项删除;本人和其他人的性别都在「星盘档案」→「编辑」里改,详情卡照常显示。已保存的性别不受影响。Skill 版本不 bump。
|
||||
|
||||
## 2026-09-30 — 点踩会把这一轮发给我们排查,后台新增「对话质量记录」(待验收)
|
||||
|
||||
- 回答下面点 👎 后,这条回答下方出现一块小面板「哪里不满意?可以不选」:答非所问 / 内容不准 / 太长太空 / 语气不对 / 其他,可再补一句(最多 200 字)。全部选填,面板上写明「会把这一轮对话发给我们排查」。
|
||||
|
||||
@@ -14935,3 +14935,16 @@
|
||||
- 防复发:新增或修改迁移的轮次,交付前必须在真实 PostgreSQL 上跑 `database-*` 测试;无 Docker 时用同样的本机替身(方法见 PROGRESS-compliance-launch-20260930)。触发器函数跨表复用时不得直接引用只存在于某一张表的列。
|
||||
- 相关记录:TASK / PROGRESS-account-deletion-20260930、PROGRESS-compliance-launch-20260930
|
||||
- 修复版本:分支 `codex/compliance-launch-20260930`
|
||||
|
||||
## BUG-1119 | 本人性别有两个入口:设置 →「个人资料」与星盘档案编辑表单
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署)
|
||||
- 首次发现 / 最近更新:2026-09-30 / 2026-09-30
|
||||
- 影响面:`frontend/src/components/profile-panel.tsx`(`OwnerGenderSection`)、`frontend/src/hooks/use-profile-onboarding.ts::persistGender`。
|
||||
- 现象:产品反馈「性别应该在档案里设置而不是在个人资料的账户里设置」——账户设置里出现性别单选,与星盘档案里的编辑表单重复。
|
||||
- 根因:性别选填(2026-09-27)上线时档案里本人不能编辑,个人资料是本人唯一入口;BUG-1081(09-28)让本人在档案可编辑并带性别后,个人资料那一处没有撤掉。
|
||||
- 修复:删除个人资料里的性别区与首页 hook 的 `persistGender`、`withSavedGender`;本人性别只经档案编辑表单保存(`saveSelfGender`,单字段 PATCH 不变)。
|
||||
- 验证:`profile-gender-ui-20260927.test.tsx` 同名用例改为断言个人资料无性别单选、档案走 `saveSelfGender`(三栏注释);前端全量 4,470 条,失败 24 条与基线逐条相同;tsc 0、lint 0 error。
|
||||
- 防复发:同一字段新增编辑入口时,同一轮撤掉旧入口并在 DESIGN 标明唯一入口。
|
||||
- 相关记录:BUG-1081、TASK-consult-gender-optional-20260927
|
||||
- 修复版本:分支 `codex/gender-archive-only-20260930`
|
||||
|
||||
+2
-2
@@ -742,7 +742,7 @@ The 👍 / 👎 under an answer (回复评价) is saved per conversation and mes
|
||||
- **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. 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.
|
||||
- **Personal profile:** one row of avatar editing (56px preview, eight palettes, 换一个形象) plus nickname and login email. Gender is **not** set here (2026-09-30, BUG-1119): it lives only in the people archive's edit form, for the owner and everyone else; 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, but is edited here like everyone else (2026-09-28, BUG-1081). 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.
|
||||
@@ -1086,7 +1086,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- **列表行**(`.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)。他人另有「和我合盘」;每个人底部都有「编辑」,本人在它上面加一行灰字「本人不能删除」。**本人的编辑视图(2026-09-28,BUG-1081)**与他人同一张表(称呼、出生资料、岁差、性别),标题「编辑我的资料」、说明「称呼和出生资料。」,没有删除区;保存走账户资料接口,保存后首页、星盘、星历按新资料重读。
|
||||
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前,与个人资料同一个控件(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。
|
||||
- **编辑表单里的性别(选填)**(2026-09-27):放在出生地点之后、岁差之前(女 / 男 / 不填,44px,一行说明),随「保存」一起提交;选「不填」存为空。自 2026-09-30(BUG-1119)起这是设置性别的唯一入口,个人资料里不再有。
|
||||
- **删除只在编辑视图底部**,单独一张卡,删除确认、用量计数、查询失败句全部沿用。
|
||||
- **头像颜色**:本人用 `--color-action-strong` + `--color-on-dark`(同账户首字圆);其他人按 id 稳定地取四个元素色 `--color-element-fire / earth / air / water` 之一,字用 `--color-ink`。不新造色值。
|
||||
|
||||
|
||||
@@ -319,7 +319,6 @@ export default function Home() {
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
saveOnboardingName,
|
||||
saveOnboardingBirth,
|
||||
saveOnboardingPlace,
|
||||
@@ -1129,7 +1128,7 @@ export default function Home() {
|
||||
overlayRef: accountDialog,
|
||||
closeButtonRef: closeButton,
|
||||
renderProfile: () => (
|
||||
<ProfilePanel account={account} profile={profile} avatarSaving={avatarSaving} avatarNotice={avatarNotice} accountError={accountError} persistAvatar={persistAvatar} persistGender={persistGender} />
|
||||
<ProfilePanel account={account} profile={profile} avatarSaving={avatarSaving} avatarNotice={avatarNotice} accountError={accountError} persistAvatar={persistAvatar} />
|
||||
),
|
||||
renderChartLibrary() {
|
||||
return null;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"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;
|
||||
@@ -15,8 +11,6 @@ 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({
|
||||
@@ -26,7 +20,6 @@ export function ProfilePanel({
|
||||
avatarNotice,
|
||||
accountError,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
}: ProfilePanelProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -77,40 +70,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
readProfile,
|
||||
selectedBirthPlace,
|
||||
} from "@/lib/home-profile";
|
||||
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";
|
||||
@@ -194,26 +193,6 @@ 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;
|
||||
@@ -384,7 +363,6 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
||||
openAccountDialog,
|
||||
closeAccountDialog,
|
||||
persistAvatar,
|
||||
persistGender,
|
||||
persistProfile,
|
||||
assessSavedBirthTime,
|
||||
saveOnboardingName,
|
||||
|
||||
@@ -35,9 +35,3 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// TASK-consult-gender-optional-20260927 T2: optional gender radio (女 / 男 / 不填)
|
||||
// in the people-archive edit form and the owner's 个人资料, one explanatory
|
||||
// in the people-archive edit form (the only place since 2026-09-30), one explanatory
|
||||
// line, and the detail card shows the stored value (未填 when empty).
|
||||
// Fictional people only (AGENTS §8).
|
||||
import assert from "node:assert/strict";
|
||||
@@ -125,16 +125,24 @@ test("detail card: 性别 shows the stored value, 未填 when empty; 本人 edit
|
||||
});
|
||||
|
||||
test("个人资料: the owner's gender saves on its own and birth-data saves never send it", () => {
|
||||
// 原值: 个人资料 rendered the 女 / 男 / 不填 radios (["女:true", "男:false", "不填:false"]) plus the hint, and
|
||||
// use-profile-onboarding's persistGender sent the one-field PATCH
|
||||
// 新值: 个人资料 has no gender control; the one-field PATCH is saveSelfGender, called from the people archive's self edit
|
||||
// 原因: 产品 2026-09-30「性别应该在档案里设置而不是在个人资料的账户里设置」——两个入口删一个(BUG-1119)
|
||||
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 () => {}} />,
|
||||
accountError="" persistAvatar={() => {}} />,
|
||||
);
|
||||
assert.deepEqual(radios(panel), ["女:true", "男:false", "不填:false"]);
|
||||
assert.ok(panel.includes(PROFILE_GENDER_HINT));
|
||||
assert.deepEqual(radios(panel), []);
|
||||
assert.equal(panel.includes(PROFILE_GENDER_HINT), false);
|
||||
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
assert.match(people, /await saveSelfGender\(gender\)/);
|
||||
const selfSave = readFileSync(new URL("../src/lib/self-profile-save.ts", import.meta.url), "utf8");
|
||||
const saveGender = selfSave.slice(selfSave.indexOf("export async function saveSelfGender"));
|
||||
assert.match(saveGender, /body: JSON\.stringify\(\{ gender \}\)/);
|
||||
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 \}\)/);
|
||||
assert.equal(/persistGender/.test(hook), false, "no second gender entry in the home hook");
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user