diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ff9840..77c85b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 合盘报告有自己的地址 + +- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。 + ## 2026-10-21 — 合盘报告两侧空白也能滚动 - 合盘报告改由整宽的外层滚动,中间一栏只负责居中排版。指针停在两侧空白处时,整份报告一起动。打印仍按整页展开。Skill 版本不变(BUG-1404)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 961bafa1..a42a32ad 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 diff --git a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md index ecd25be1..8ed3e45e 100644 --- a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md +++ b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md @@ -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`,因为合盘地址要能认出个人报告。 +- 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 07a88312..371eb229 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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,在途) diff --git a/frontend/src/app/(app)/synastry/[reportId]/page.tsx b/frontend/src/app/(app)/synastry/[reportId]/page.tsx new file mode 100644 index 00000000..7610d98a --- /dev/null +++ b/frontend/src/app/(app)/synastry/[reportId]/page.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 6d83bb50..25a66adf 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -28,6 +28,11 @@ import { const LIST_POLL_INTERVAL_MS = 3000; +function reportOpenHref(report: Pick): 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() { {report.status === "ready" ? (
-
) : report.status === "generating" ? (
-
diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index e8d4c972..cb1c575e 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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({ phase: "loading" }); const [language, setLanguage] = useState(initialLanguage); const [drawnThrough, setDrawnThrough] = useState(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 ; + if (redirectTo) { + return ; + } + 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 ( <> - +
diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index 541768bc..a03cc0da 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -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() {
    {earlier.map((report) => (
  • - + {historyWho(report.cardSummary)} {report.createdAt ? {historyWhen(report.createdAt)} : null} diff --git a/frontend/src/lib/synastry-report-href.ts b/frontend/src/lib/synastry-report-href.ts new file mode 100644 index 00000000..51fb1e9f --- /dev/null +++ b/frontend/src/lib/synastry-report-href.ts @@ -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; +} diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index d35b63ff..31b2e3df 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -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" }), { diff --git a/frontend/tests/synastry-report-surface.test.ts b/frontend/tests/synastry-report-surface.test.ts new file mode 100644 index 00000000..005bf978 --- /dev/null +++ b/frontend/tests/synastry-report-surface.test.ts @@ -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\}`/); +});