fix(synastry): open the report on its own address

The shared report URL lit the personal-report item in the sidebar. The new address keeps the synastry item current and replaces the old link.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-21 18:07:51 +08:00
co-authored by Cursor
parent 9d2f63b6ac
commit dc80a43aae
11 changed files with 186 additions and 9 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-21 — 合盘报告有自己的地址
- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。
## 2026-10-21 — 合盘报告两侧空白也能滚动
- 合盘报告改由整宽的外层滚动,中间一栏只负责居中排版。指针停在两侧空白处时,整份报告一起动。打印仍按整页展开。Skill 版本不变(BUG-1404)。
+16
View File
@@ -37031,3 +37031,19 @@
- 相关记录:issue #85。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
## BUG-1405 | 合盘报告打开后侧栏变成「我的报告」
- 状态:resolved(待 staging 真机复测)
- 首次发现:2026-10-21(产品负责人真机)
- 最近更新:2026-10-21
- 影响面:婚恋合盘报告的地址、报告中心卡片、选人页的「开始合盘」和「之前的合盘」
- 用户现象:点「开始合盘」之后,侧栏当前项从「婚恋合盘」变成「我的报告」。浏览器标题也是「个人报告」。
- 触发条件:从选人页进入一份合盘,或从报告中心打开合盘卡片。地址是 `/reports/报告`。
- 根因:合盘和个盘共用个人报告阅读页。侧栏按地址前缀点亮,「我的报告」是 `/reports`。任务书原先只要求跳进阅读页,没有给合盘单独的地址。
- 修复:新增 `/synastry/报告`,仍用原来的阅读页,文档标题是「婚恋合盘 · Jyotisha」,`?lang=` 保留。选人页的「开始合盘」和「之前的合盘」、报告中心的合盘卡片改走这个地址。旧的 `/reports/报告` 打开合盘时换成新地址,不另留一条历史。新地址若打开的是个人报告,换回 `/reports/报告`。左上角「‹ 婚恋合盘」不变。打印样式只在打印时收起侧栏。侧栏本来就用地址前缀点亮 `/synastry`,所以新地址仍是「婚恋合盘」。
- 验证:`frontend/tests/synastry-report-surface.test.ts`。旧地址带 `lang=en` 换成 `/synastry/报告?lang=en`;个人报告留在 `/reports`;新页面标题和 `surface="synastry"`;选人页和报告中心的链接;侧栏对 `/synastry/` 前缀点亮。失败态带上报告类型,个人报告的失败断言从「没有 reportType」改为带上 `personal_full`。
- 防复发:上述测试。不要把合盘再链回 `/reports/报告`。
- 相关记录:issue #85。BUG-1404。
- 复发自:无。
- 修复版本:分支 `codex/synastry-staging-fix-20261021`。
@@ -50,3 +50,12 @@
- 合盘报告外层改为整宽的 `.synastry-report-scroll`(`width: 100%`、`height: 100%`、`overflow-y: auto`)。`.synastry-report` 只居中排版,不再自带 `height: 100%` 和 `overflow`。打印时外层 `height: auto`、`overflow: visible`。
- 真机(执行方未做,不得写成已通过):桌面打开一份合盘报告,把指针放在正文栏两侧的空白上滚动,整份报告应跟着动。窄屏和打印另看一眼,长报告不要被裁成一页。
## T7 独立地址(BUG-1405)
- 新页面 `/synastry/[reportId]` 复用 `PersonalReportPage`,`surface="synastry"`,文档标题「婚恋合盘 · Jyotisha」,`lang` 仍走 `readReportLanguage`。
- 选人页「开始合盘」和「之前的合盘」、报告中心的合盘卡片改到 `/synastry/报告`。个人报告卡片仍是 `/reports/报告`。
- 旧地址打开合盘时 `location.replace` 到新地址,英文保留 `?lang=en`,不另留历史。新地址打开个人报告时换回 `/reports/报告`。
- 合盘揭幕后的打印样式加上 `media="print"`,平时侧栏还在。
- 失败态带上 `reportType`。个人报告失败断言的原值是没有这个字段,新值带上 `personal_full`,因为合盘地址要能认出个人报告。
- 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。
+1 -1
View File
@@ -2,7 +2,7 @@
## 婚恋合盘报告(2026-10-19)
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { PersonalReportPage } from "@/components/personal-report/personal-report-page";
import { readReportLanguage } from "@/lib/report-language";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "婚恋合盘 · Jyotisha",
robots: { index: false, follow: false },
};
export default async function SynastryReportPage({
params,
searchParams,
}: {
params: Promise<{ reportId: string }>;
searchParams: Promise<{ lang?: string | string[] }>;
}) {
const { reportId } = await params;
const { lang } = await searchParams;
return (
<PersonalReportPage
reportId={reportId}
initialLanguage={readReportLanguage(lang)}
surface="synastry"
/>
);
}
@@ -28,6 +28,11 @@ import {
const LIST_POLL_INTERVAL_MS = 3000;
function reportOpenHref(report: Pick<ReportListItem, "id" | "reportType">): string {
const id = encodeURIComponent(report.id);
return report.reportType === "synastry_full" ? `/synastry/${id}` : `/reports/${id}`;
}
/**
* `nextCursor` is where 「加载更多」 continues; `extended` says the user has
* opened pages past the first, so a refreshed first page is merged over them
@@ -258,13 +263,13 @@ export function PersonalReportCenter() {
</div>
{report.status === "ready" ? (
<div className="report-center-row-actions">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
查看报告
</Button>
</div>
) : report.status === "generating" ? (
<div className="report-center-row-actions">
<Button render={<AppLink href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
<Button render={<AppLink href={reportOpenHref(report)} />} nativeButton={false} variant="outline">
查看进度
</Button>
</div>
@@ -35,6 +35,7 @@ import {
} from "@/lib/personal-report-longform-copy";
import { projectOrdinaryReportMarkdown } from "@/lib/report-public-projection";
import { containsHan, type EnglishEditionStatus, type ReportLanguage } from "@/lib/report-language";
import { reportSurfaceRedirect, reportTypeForSurface, type ReportSurface } from "@/lib/synastry-report-href";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
@@ -57,7 +58,7 @@ export type ReportLoadState =
sections?: readonly ReportSectionProgress[];
}
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null; reportType?: string }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| {
@@ -200,6 +201,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
return {
phase: "failed",
failureCode: appendixLastErrorCode ?? code,
...(typeof view.reportType === "string" ? { reportType: view.reportType } : {}),
...(summary ? { failureSummary: summary } : {}),
...(appendixLastErrorCode ? { appendixLastErrorCode } : {}),
};
@@ -291,7 +293,15 @@ export function formatWaitedDuration(elapsedMs: number): string {
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
}
export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { reportId: string; initialLanguage?: ReportLanguage }) {
export function PersonalReportPage({
reportId,
initialLanguage = "zh",
surface = "personal",
}: {
reportId: string;
initialLanguage?: ReportLanguage;
surface?: ReportSurface;
}) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage);
const [drawnThrough, setDrawnThrough] = useState<number | null>(null);
@@ -413,8 +423,23 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
onPoll: tick,
});
const redirectTo = reportSurfaceRedirect({
surface,
reportType: reportTypeForSurface(state),
reportId,
language,
});
useEffect(() => {
if (!redirectTo) return;
window.location.replace(redirectTo);
}, [redirectTo]);
if (state.phase === "loading") return <ReportOpening />;
if (redirectTo) {
return <SecondaryHeader title={redirectTo.startsWith("/synastry/") ? "婚恋合盘" : "个人报告"} />;
}
if (state.phase === "generating" && state.reportType === "synastry_full") {
return (
<>
@@ -554,7 +579,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
if (state.phase === "synastry-ready") {
return (
<>
<style>{REPORT_SHELL_PRINT_CSS}</style>
<style media="print">{REPORT_SHELL_PRINT_CSS}</style>
<SecondaryHeader title="婚恋合盘" />
<div className="synastry-report-scroll">
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
@@ -161,7 +161,7 @@ export function SynastryPicker() {
b: people.find((person) => person.id === right)?.name ?? "",
};
sessionStorage.setItem(`synastry-wait:${payload.report.id}`, JSON.stringify(pair));
window.location.assign(`/reports/${payload.report.id}`);
window.location.assign(`/synastry/${payload.report.id}`);
} finally {
setBusy(false);
}
@@ -224,7 +224,7 @@ export function SynastryPicker() {
<ul>
{earlier.map((report) => (
<li key={report.id}>
<a className="synastry-history-item" href={`/reports/${report.id}`}>
<a className="synastry-history-item" href={`/synastry/${report.id}`}>
<span className="who">{historyWho(report.cardSummary)}</span>
{report.createdAt ? <span className="meta">{historyWhen(report.createdAt)}</span> : null}
</a>
+34
View File
@@ -0,0 +1,34 @@
export type ReportSurface = "personal" | "synastry";
export function reportReaderHref(surface: ReportSurface, reportId: string, language: "zh" | "en"): string {
const path = surface === "synastry"
? `/synastry/${encodeURIComponent(reportId)}`
: `/reports/${encodeURIComponent(reportId)}`;
return language === "en" ? `${path}?lang=en` : path;
}
/**
* Old shared links still open /reports/:id. A synastry report there moves to
* /synastry/:id, and a personal report opened on the synastry URL moves back.
* Unknown types stay put. English keeps ?lang=en; Chinese keeps a clean path.
*/
export function reportSurfaceRedirect(input: {
surface: ReportSurface;
reportType: string | null;
reportId: string;
language: "zh" | "en";
}): string | null {
if (!input.reportType) return null;
const synastry = input.reportType === "synastry_full";
if (input.surface === "personal" && synastry) return reportReaderHref("synastry", input.reportId, input.language);
if (input.surface === "synastry" && !synastry) return reportReaderHref("personal", input.reportId, input.language);
return null;
}
export function reportTypeForSurface(state: { phase: string; reportType?: string }): string | null {
if (state.phase === "synastry-ready") return "synastry_full";
if (state.phase === "generating") return state.reportType ?? "personal_full";
if (state.phase === "markdown-ready" || state.phase === "legacy-unavailable") return "personal_full";
if (state.phase === "failed") return state.reportType ?? null;
return null;
}
+11 -1
View File
@@ -63,9 +63,13 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
assert.equal(readyV2.phase, "markdown-ready");
assert.deepEqual(classifyReportEnvelope(200, { report: view("generating") }), { phase: "generating" });
// 原值:失败态只有 phase 和 failureCode(以及摘要、附录错误码)
// 新值:失败态带上 reportType
// 原因:合盘地址要能把个人报告换回 /reports,失败的报告也要认出类型
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed", "model_unavailable") }), {
phase: "failed",
failureCode: "model_unavailable",
reportType: "personal_full",
});
assert.deepEqual(
classifyReportEnvelope(200, {
@@ -75,6 +79,7 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
phase: "failed",
failureCode: "report_schema_invalid",
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
reportType: "personal_full",
},
);
assert.deepEqual(
@@ -90,9 +95,14 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
failureCode: "appendix_persist_failed",
failureSummary: "报告正文未能保存,请稍后重试",
appendixLastErrorCode: "appendix_persist_failed",
reportType: "personal_full",
},
);
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), {
phase: "failed",
failureCode: null,
reportType: "personal_full",
});
// Server-side error envelopes carry a stable code at the top level.
assert.deepEqual(classifyReportEnvelope(503, { error: "x", code: "report_persistence_unavailable" }), {
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { reportSurfaceRedirect } from "../src/lib/synastry-report-href.ts";
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("an old synastry url moves to the synastry route and keeps the language", () => {
assert.equal(
reportSurfaceRedirect({ surface: "personal", reportType: "synastry_full", reportId: "abc", language: "en" }),
"/synastry/abc?lang=en",
);
assert.equal(
reportSurfaceRedirect({ surface: "synastry", reportType: "personal_full", reportId: "abc", language: "zh" }),
"/reports/abc",
);
assert.equal(
reportSurfaceRedirect({ surface: "synastry", reportType: "synastry_full", reportId: "abc", language: "zh" }),
null,
);
assert.equal(
reportSurfaceRedirect({ surface: "personal", reportType: "personal_full", reportId: "abc", language: "en" }),
null,
);
});
test("the synastry route reuses the reader and the sidebar stays on 婚恋合盘", () => {
const page = read("../src/app/(app)/synastry/[reportId]/page.tsx");
const reader = read("../src/components/personal-report/personal-report-page.tsx");
const sidebar = read("../src/components/app-sidebar.tsx");
const picker = read("../src/components/synastry/synastry-picker.tsx");
const center = read("../src/components/personal-report/personal-report-center.tsx");
assert.match(page, /title: "婚恋合盘 · Jyotisha"/);
assert.match(page, /readReportLanguage\(lang\)/);
assert.match(page, /surface="synastry"/);
assert.match(reader, /window\.location\.replace\(redirectTo\)/);
assert.match(sidebar, /\{ href: "\/synastry", label: "婚恋合盘"/);
assert.match(sidebar, /pathname === href \|\| pathname\.startsWith\(`\$\{href\}\/`\)/);
assert.equal("/synastry/abc".startsWith("/synastry/"), true);
assert.match(picker, /window\.location\.assign\(`\/synastry\/\$\{payload\.report\.id\}`\)/);
assert.match(picker, /href=\{`\/synastry\/\$\{report\.id\}`\}/);
assert.doesNotMatch(picker, /\/reports\/\$\{/);
assert.match(center, /reportType === "synastry_full" \? `\/synastry\/\$\{id\}` : `\/reports\/\$\{id\}`/);
});