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:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-21 — 合盘报告有自己的地址
|
||||
|
||||
- 合盘报告改走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍是「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,英文链接保留 `?lang=en`。Skill 版本不变(BUG-1405)。
|
||||
|
||||
## 2026-10-21 — 合盘报告两侧空白也能滚动
|
||||
|
||||
- 合盘报告改由整宽的外层滚动,中间一栏只负责居中排版。指针停在两侧空白处时,整份报告一起动。打印仍按整页展开。Skill 版本不变(BUG-1404)。
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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\}`/);
|
||||
});
|
||||
Reference in New Issue
Block a user