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\}`/);
+});