feat(synastry): lay out the match screens from the confirmed prototype
Independent Staging Quality Gate / validate (push) Failing after 18m11s
Independent Staging Quality Gate / publish (push) Skipped

The add-person card, the generating wait, and the report header follow the prototype the product owner pointed at.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-20 17:22:17 +08:00
co-authored by Cursor
parent 0a5d93ab75
commit 67c1e68815
10 changed files with 897 additions and 186 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-20 — 婚恋合盘选人页按已确认原型排版
- 选人页、新增星盘、生成中和报告页按已确认原型排版。新增是一张卡片;生成中是轨道和四步;报告有返回、日期和中英文切换。Skill 版本不变。
## 2026-10-20 — 后台可以修改并删除已发布模型
- 编辑已发布或已下线的模型会另存成下一版草稿,当前线上版本保持不变。更换供应商模型时,显示名跟着新模型走。
+1 -1
View File
@@ -2,7 +2,7 @@
## 婚恋合盘报告(2026-10-19)
选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。报告末尾一行「对哪一段想再问清楚?」,下面是八枚描边胶囊按钮(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送,也不出现新的加载动画。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
+1 -1
View File
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 吵完之后,怎样递出台阶才不会变成认输?
- 过日子的安排里,哪一件需要现在就说清楚?
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
+278 -41
View File
@@ -2058,10 +2058,14 @@ button.chart-library-item { cursor: pointer; text-align: left; }
.synastry-report-card strong, .synastry-report-card p { color: var(--color-ink); }
.synastry-report-card strong { display: block; margin-top: 4px; font-size: var(--type-body-md); font-weight: 500; }
.synastry-report-card p, .synastry-report-card ul { margin: 0; }
.synastry-history-list { display: grid; gap: var(--space-2); }
.synastry-history-list > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; }
.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-history-list { display: flex; flex-direction: column; gap: var(--space-2); }
.synastry-history-list h2 { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 500; }
.synastry-history-list ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--color-border); }
.synastry-history-list li { border-bottom: 1px solid var(--color-border); }
.synastry-history-item { display: flex; gap: var(--space-3); align-items: baseline; flex-wrap: wrap; padding: 10px 4px; color: var(--color-ink); text-decoration: none; }
.synastry-history-item:hover { background: var(--color-canvas-muted); }
.synastry-history-item .who { font-weight: 500; }
.synastry-history-item .meta { margin-left: auto; color: var(--color-ink-tertiary); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input), select:not([class^="ant-"]):not([class*=" ant-"]) { width: 100%; min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: var(--type-body-md); }
input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class*=" ant-"]):not(.ant-picker input):disabled, select:not([class^="ant-"]):not([class*=" ant-"]):disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
@@ -4520,7 +4524,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
line-height: 1.45;
}
.synastry-report,
.synastry-picker {
width: 100%;
max-width: 100%;
@@ -4528,55 +4531,289 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
padding: var(--space-6);
}
.synastry-picker-list {
max-height: 16rem;
overflow-y: auto;
}
.synastry-report details,
.synastry-technical {
margin-top: var(--space-3);
}
.synastry-report-heading {
.synastry-picker {
width: min(720px, 100%);
margin-inline: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
flex-direction: column;
gap: 28px;
}
.synastry-report-heading h1,
.synastry-technical summary {
color: var(--color-ink);
}
.synastry-report-ask {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-6);
}
.synastry-report-ask p {
flex-basis: 100%;
.synastry-page-heading { display: flex; flex-direction: column; gap: 4px; }
.synastry-page-heading h1 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 500;
text-wrap: balance;
}
.synastry-page-sub { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-body-sm); }
.synastry-pick {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: var(--space-3);
align-items: end;
}
.synastry-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.synastry-field > span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-times {
padding-bottom: 6px;
color: var(--color-ink-tertiary);
font-family: var(--font-display);
font-size: var(--type-title-lg);
}
.synastry-dd { position: relative; min-width: 0; }
.synastry-pick:has(.synastry-dd-list) { position: relative; z-index: 5; }
.synastry-dd-btn {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 10px 12px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.synastry-dd-btn .ph { color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 400; }
.synastry-dd-main { min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); }
.synastry-dd-main b { font-weight: 500; }
.synastry-dd-btn::after {
content: "";
margin-left: auto;
width: 7px;
height: 7px;
flex: none;
border-right: 1.5px solid var(--color-ink-secondary);
border-bottom: 1.5px solid var(--color-ink-secondary);
transform: rotate(45deg) translateY(-2px);
}
.synastry-dd-list {
position: absolute;
z-index: 4;
left: 0;
right: 0;
top: calc(100% + 4px);
max-height: 320px;
overflow-y: auto;
margin: 0;
padding: 6px;
list-style: none;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-canvas);
box-shadow: 0 8px 24px rgba(0, 0, 0, .12);
}
.synastry-dd-list button {
width: 100%;
display: flex;
gap: var(--space-2);
align-items: baseline;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-ink);
font: inherit;
text-align: left;
cursor: pointer;
}
.synastry-dd-list button b { font-weight: 500; }
.synastry-dd-list button small { margin-left: auto; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.synastry-dd-list button:hover:not(:disabled),
.synastry-dd-list button:focus-visible { background: var(--color-canvas-muted); }
.synastry-dd-list button:disabled { color: var(--color-ink-tertiary); cursor: default; opacity: 1; }
.synastry-dd-add {
border-top: 1px solid var(--color-border);
margin-top: 4px;
color: var(--color-action);
}
button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.synastry-dd-add.is-full {
display: flex;
gap: var(--space-2);
align-items: baseline;
padding: 10px;
color: var(--color-ink-tertiary);
}
.synastry-dd-add.is-full a { margin-left: auto; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.synastry-go { display: flex; flex-direction: column; gap: var(--space-2); align-items: center; text-align: center; }
.synastry-go .button-primary { min-width: 200px; }
.synastry-cost { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
.synastry-dialog-back {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: rgba(20, 18, 15, .38);
}
.synastry-dialog {
width: min(440px, 100%);
max-height: min(88vh, 760px);
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding: 22px;
border-radius: 16px;
background: var(--color-canvas);
box-shadow: 0 20px 50px rgba(0, 0, 0, .25);
}
.synastry-dialog h2 { margin: 0; color: var(--color-ink); font-size: 20px; font-weight: 500; }
.synastry-add-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.synastry-add-grid .full { grid-column: 1 / -1; }
.synastry-add-grid label { display: flex; flex-direction: column; gap: 6px; min-width: 0; color: var(--color-ink-secondary); font-size: 13px; }
.synastry-dialog input {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--color-border-strong);
border-radius: 9px;
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
}
.synastry-seg { display: flex; gap: 6px; }
.synastry-seg button {
flex: 1;
padding: 8px;
border: 1px solid var(--color-border-strong);
border-radius: 9px;
background: var(--color-canvas);
color: var(--color-ink-secondary);
font: inherit;
cursor: pointer;
}
.synastry-seg button[aria-pressed="true"] {
border-color: var(--color-action);
background: var(--color-action-soft);
color: var(--color-action);
}
.synastry-place-hits { margin: 6px 0 0; padding: 4px; list-style: none; border: 1px solid var(--color-border-strong); border-radius: 9px; background: var(--color-canvas); }
.synastry-place-hits button { width: 100%; padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--color-ink); font: inherit; text-align: left; cursor: pointer; }
.synastry-place-hits button:hover { background: var(--color-canvas-muted); }
.synastry-add-acts { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
@media (max-width: 760px) {
.synastry-pick { grid-template-columns: minmax(0, 1fr); }
.synastry-times { display: none; }
.synastry-add-grid { grid-template-columns: 1fr; }
}
.synastry-wait {
width: min(720px, 100%);
height: 100%;
min-height: 0;
margin-inline: auto;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 80px var(--space-6);
overflow: auto;
color: var(--color-ink-secondary);
text-align: center;
}
.synastry-wait p { margin: 0; }
.synastry-wait .sub { max-width: 36rem; color: var(--color-ink-tertiary); font-size: 14px; line-height: 1.6; }
.synastry-orbit { position: relative; width: 56px; height: 56px; min-height: 56px; flex: none; }
.synastry-orbit i { position: absolute; inset: 0; border: 1.5px solid var(--color-border-strong); border-radius: 50%; }
.synastry-orbit b {
position: absolute;
top: -5px;
left: 23px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-action);
transform-origin: 5px 33px;
animation: synastry-spin 1.6s linear infinite;
}
.synastry-orbit b + b { background: var(--color-ink-secondary); animation-delay: -.8s; }
@keyframes synastry-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .synastry-orbit b { animation: none; } }
.synastry-steps {
margin: 4px 0 0;
padding-left: 1.3em;
display: grid;
gap: 4px;
color: var(--color-ink-secondary);
font-size: 14px;
text-align: left;
}
.synastry-steps li.done { color: var(--color-ink-tertiary); text-decoration: line-through; }
.synastry-report {
width: min(720px, 100%);
height: 100%;
min-height: 0;
margin-inline: auto;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 22px;
padding: var(--space-6);
overflow: auto;
overflow-wrap: anywhere;
}
.synastry-report-back { color: var(--color-ink-secondary); font-size: 14px; text-decoration: none; }
.synastry-report-back:hover { color: var(--color-ink); }
.synastry-report-heading { display: flex; flex-direction: column; gap: 8px; }
.synastry-report-heading h1 { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: 28px; font-weight: 500; }
.synastry-report-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--color-ink-tertiary); font-size: 14px; }
.synastry-lang { display: inline-flex; gap: 6px; margin-left: auto; }
.synastry-lang button {
padding: 6px 12px;
border: 1px solid var(--color-border-strong);
border-radius: 9px;
background: var(--color-canvas);
color: var(--color-ink-secondary);
font: inherit;
cursor: pointer;
}
.synastry-lang button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); }
.synastry-section h2, .synastry-talk h2 { margin: 0 0 8px; color: var(--color-ink); font-size: 19px; font-weight: 500; }
.synastry-section p, .synastry-talk li { margin: 0; color: var(--color-ink); font-size: 16px; line-height: 1.85; }
.synastry-section .lead { margin-top: 0; color: var(--color-ink-secondary); font-size: 14.5px; }
.synastry-tl { list-style: none; margin: 6px 0 0; padding: 0 0 0 16px; display: grid; gap: 16px; border-left: 2px solid var(--color-border); }
.synastry-tl li { position: relative; }
.synastry-tl li::before { content: ""; position: absolute; left: -22px; top: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--color-action); }
.synastry-tl-h b { font-weight: 500; }
.synastry-why { margin-top: 12px; padding: 8px 14px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-canvas); }
.synastry-why summary { color: var(--color-ink-secondary); font-size: 14px; cursor: pointer; }
.synastry-talk ul { margin: 0; padding-left: 1.2em; display: grid; gap: 6px; }
.synastry-askbox { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; border-radius: 12px; background: var(--color-action-soft); color: var(--color-ink-secondary); font-size: 15px; }
.synastry-askbox p { margin: 0; }
.synastry-report-ask { display: flex; flex-wrap: wrap; gap: 8px; }
.synastry-report-ask button {
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.synastry-report-ask button:disabled {
cursor: default;
opacity: 0.6;
}
.synastry-report-ask button:disabled { cursor: default; opacity: 0.6; }
.synastry-technical { padding: 10px 14px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-canvas); }
.synastry-technical summary { color: var(--color-ink-secondary); font-size: 15px; cursor: pointer; }
.synastry-technical h3 { margin: 18px 0 6px; color: var(--color-ink); font-size: 15.5px; }
.synastry-technical li { color: var(--color-ink-secondary); font-size: 14px; line-height: 1.7; }
.synastry-footnote { margin: 0; color: var(--color-ink-tertiary); font-size: 12.5px; }
.personal-report-state {
width: 100%;
@@ -20,6 +20,7 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
import { SynastryReportView } from "@/components/synastry/synastry-report-view";
import { SynastryWait } from "@/components/synastry/synastry-wait";
import { ReportActions } from "./report-actions";
import { ReportFactTables } from "./report-fact-tables";
import { ReportCalculationCharts } from "./report-calculation-charts";
@@ -50,6 +51,7 @@ export type ReportLoadState =
| { phase: "not-found" }
| {
phase: "generating";
reportType?: "synastry_full";
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
@@ -73,7 +75,7 @@ export type ReportLoadState =
factTablesEn?: ReportFactTable[];
}
| { phase: "legacy-unavailable" }
| { phase: "synastry-ready"; reportId: string; synastry: unknown };
| { phase: "synastry-ready"; reportId: string; synastry: unknown; createdAt?: string };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
export interface ReportEnvelopeView {
@@ -149,6 +151,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
phase: "synastry-ready",
reportId: typeof view.id === "string" ? view.id : "",
synastry: json.synastry,
...(typeof view.createdAt === "string" ? { createdAt: view.createdAt } : {}),
};
}
const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : "";
@@ -177,6 +180,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const sections = readSectionProgress(view.sections);
return {
phase: "generating",
...(view.reportType === "synastry_full" ? { reportType: "synastry_full" as const } : {}),
...(progressPercent === undefined ? {} : { progressPercent }),
...(progressPhase === undefined ? {} : { progressPhase }),
...(sections === undefined ? {} : { sections }),
@@ -411,6 +415,15 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
if (state.phase === "loading") return <ReportOpening />;
if (state.phase === "generating" && state.reportType === "synastry_full") {
return (
<>
<SecondaryHeader title="婚恋合盘" />
<SynastryWait reportId={reportId} phase={state.progressPhase} />
</>
);
}
if (state.phase === "generating") {
// The stall clock rides the existing one-second tick: waitStartedAt +
// waitedMs is "now" without a second timer or an effect.
@@ -543,7 +556,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<>
<style>{REPORT_SHELL_PRINT_CSS}</style>
<SecondaryHeader title="婚恋合盘" />
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} initialLanguage={language} />
<SynastryReportView reportId={state.reportId} snapshot={state.synastry} createdAt={state.createdAt} initialLanguage={language} />
</>
);
}
@@ -0,0 +1,192 @@
"use client";
import { useMemo, useState } from "react";
import type { FormEvent } from "react";
import { chinaProvinces } from "@/lib/china-birth-place";
import { birthTimeSourceDefaults } from "@/lib/birth-time-intake-model";
import { emptyProfile, type Profile } from "@/lib/home-types";
import { PROFILE_GENDER_LABELS, parseProfileGender, type ProfileGender } from "@/lib/profile-gender";
type CityHit = {
key: string;
label: string;
provinceCode: string;
cityCode: string;
latitude: number;
longitude: number;
};
export function emptySynastryDraft(): Profile {
return {
...emptyProfile,
birthTimeSource: "hospital_record",
birthTimeStatus: "reported",
...birthTimeSourceDefaults.hospital_record,
};
}
function searchCities(query: string): CityHit[] {
const needle = query.trim();
if (!needle) return [];
const hits: CityHit[] = [];
for (const province of chinaProvinces) {
for (const city of province.cities) {
const label = province.name === city.name ? city.name : `${province.name} · ${city.name}`;
if (!label.includes(needle) && !city.name.includes(needle)) continue;
hits.push({
key: `${province.code}/${city.code}`,
label,
provinceCode: province.code,
cityCode: city.code,
latitude: city.center[1],
longitude: city.center[0],
});
if (hits.length >= 8) return hits;
}
}
return hits;
}
export function SynastryAddDialog(props: {
value: Profile;
busy: boolean;
submitDisabled: boolean;
onChange: (profile: Profile) => void;
onCancel: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
const gender = parseProfileGender(props.value.gender);
const [placeQuery, setPlaceQuery] = useState(props.value.birthPlaceLabel);
const hits = useMemo(() => searchCities(placeQuery), [placeQuery]);
const chosen = props.value.birthPlaceLabel.trim().length > 0 && placeQuery.trim() === props.value.birthPlaceLabel.trim();
function chooseGender(next: ProfileGender) {
props.onChange({ ...props.value, gender: next });
}
function chooseCity(hit: CityHit) {
setPlaceQuery(hit.label);
props.onChange({
...props.value,
countryCode: "CN",
provinceCode: hit.provinceCode,
cityCode: hit.cityCode,
districtCode: "",
birthPlaceLabel: hit.label,
birthPlaceType: "city",
birthPlaceProvider: "china_locations",
birthPlaceProviderId: hit.cityCode,
latitude: hit.latitude,
longitude: hit.longitude,
timezoneId: "Asia/Shanghai",
timezoneSource: "iana_historical",
timezoneOffset: 8,
});
}
return (
<div className="synastry-dialog-back" onClick={props.onCancel}>
<div
className="synastry-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="synastry-add-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="synastry-add-title">新增星盘</h2>
<form onSubmit={props.onSubmit}>
<div className="synastry-add-grid">
<label className="full" htmlFor="synastry-new-name">
<span>称呼</span>
<input
id="synastry-new-name"
required
maxLength={80}
autoComplete="name"
value={props.value.name}
onChange={(event) => props.onChange({ ...props.value, name: event.target.value })}
/>
</label>
<div className="full">
<span id="synastry-new-gender">性别</span>
<div className="synastry-seg" role="group" aria-labelledby="synastry-new-gender">
<button type="button" aria-pressed={gender === "male"} onClick={() => chooseGender("male")}>{PROFILE_GENDER_LABELS.male}</button>
<button type="button" aria-pressed={gender === "female"} onClick={() => chooseGender("female")}>{PROFILE_GENDER_LABELS.female}</button>
</div>
</div>
<label htmlFor="synastry-new-date">
<span>出生日期</span>
<input
id="synastry-new-date"
inputMode="numeric"
placeholder="1993-08-21"
autoComplete="bday"
value={props.value.date}
onChange={(event) => props.onChange({ ...props.value, date: event.target.value })}
/>
</label>
<label htmlFor="synastry-new-time">
<span>出生时间</span>
<input
id="synastry-new-time"
inputMode="numeric"
placeholder="14:30"
autoComplete="off"
value={props.value.reportedTime}
onChange={(event) => props.onChange({
...props.value,
reportedTime: event.target.value,
birthTimeSource: "hospital_record",
birthTimeStatus: "reported",
...birthTimeSourceDefaults.hospital_record,
})}
/>
</label>
<div className="full synastry-add-place">
<label htmlFor="synastry-new-place">
<span>出生地</span>
<input
id="synastry-new-place"
placeholder="南京"
autoComplete="off"
value={placeQuery}
onChange={(event) => {
setPlaceQuery(event.target.value);
if (props.value.birthPlaceLabel) {
props.onChange({
...props.value,
provinceCode: "",
cityCode: "",
districtCode: "",
birthPlaceLabel: "",
latitude: null,
longitude: null,
timezoneId: "",
});
}
}}
/>
</label>
{hits.length > 0 && !chosen ? (
<ul className="synastry-place-hits">
{hits.map((hit) => (
<li key={hit.key}>
<button type="button" onClick={() => chooseCity(hit)}>{hit.label}</button>
</li>
))}
</ul>
) : null}
</div>
</div>
<div className="synastry-add-acts">
<button className="button-secondary" type="button" onClick={props.onCancel}>取消</button>
<button className="button-primary" type="submit" disabled={props.busy || props.submitDisabled}>
{props.busy ? "保存中" : "保存并选中"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,32 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { ChartProfileForm } from "@/components/chart-profile-form";
import { emptySynastryDraft, SynastryAddDialog } from "@/components/synastry/synastry-add-dialog";
import { SecondaryHeader } from "@/components/secondary-header";
import { effectiveBirthDate } from "@/lib/effective-birth-date";
import { fetchAccount } from "@/lib/home-cloud-sync";
import { emptyProfile, type Profile } from "@/lib/home-types";
import type { Profile } from "@/lib/home-types";
import { missingOtherProfileStep, readProfile } from "@/lib/home-profile";
import { peopleDisplayName } from "@/lib/people-archive-view";
import { peopleCityLabel, peopleDisplayName } from "@/lib/people-archive-view";
import { PROFILE_GENDER_LABELS, parseProfileGender } from "@/lib/profile-gender";
import { MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
type Person = { id: string; role: "self" | "other"; name: string; incomplete: boolean };
type Earlier = { id: string; cardSummary?: string };
type Person = { id: string; role: "self" | "other"; name: string; meta: string; incomplete: boolean };
type Earlier = { id: string; cardSummary?: string; createdAt?: string };
type Slot = "left" | "right";
export function SynastryPicker() {
const [people, setPeople] = useState<Person[]>([]);
const [left, setLeft] = useState("self");
const [right, setRight] = useState("");
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState<Profile>(emptyProfile);
const [open, setOpen] = useState<Slot | null>(null);
const [adding, setAdding] = useState<Slot | null>(null);
const [draft, setDraft] = useState<Profile>(emptySynastryDraft);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [creditCost, setCreditCost] = useState<number | null>(null);
const [pricingNote, setPricingNote] = useState("");
const [earlier, setEarlier] = useState<Earlier[]>([]);
const [serviceClosed, setServiceClosed] = useState(false);
const pickRef = useRef<HTMLDivElement>(null);
async function load(preferId?: string) {
async function load(prefer?: { id: string; slot: Slot }) {
const priceResponse = await fetch("/api/product-access", { cache: "no-store" });
const price = await priceResponse.json().catch(() => null) as { compatibility?: boolean; synastryCreditCost?: number | null } | null;
if (priceResponse.ok && price?.compatibility === false) {
@@ -53,17 +59,22 @@ export function SynastryPicker() {
const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> };
const self = readProfile(account.profile);
const rows: Person[] = [
{ id: "self", role: "self", name: peopleDisplayName(self, "self"), incomplete: missingOtherProfileStep(self) !== null },
personRow("self", "self", self),
...(payload.profiles ?? []).flatMap((row) => {
if (!row.id) return [];
const profile = readProfile(row.profile);
return [{ id: row.id, role: "other" as const, name: peopleDisplayName(profile, "other"), incomplete: missingOtherProfileStep(profile) !== null }];
return [personRow(row.id, "other", readProfile(row.profile))];
}),
];
setPeople(rows);
const wanted = preferId || new URLSearchParams(window.location.search).get("with") || "";
const wantedPerson = rows.find((person) => person.id === wanted);
if (wantedPerson && !wantedPerson.incomplete) setRight(wanted);
const preferPerson = prefer && rows.find((person) => person.id === prefer.id && !person.incomplete);
if (preferPerson && prefer) {
if (prefer.slot === "left") setLeft(preferPerson.id);
else setRight(preferPerson.id);
} else {
const wanted = new URLSearchParams(window.location.search).get("with") || "";
const wantedPerson = rows.find((person) => person.id === wanted);
if (wantedPerson && !wantedPerson.incomplete) setRight(wanted);
}
const historyResponse = await fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" });
const history = await historyResponse.json().catch(() => null) as { reports?: Earlier[] } | null;
setEarlier(Array.isArray(history?.reports) ? history.reports.filter((report) => report.id) : []);
@@ -76,9 +87,31 @@ export function SynastryPicker() {
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (!open && !adding) return;
function onKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
setOpen(null);
setAdding(null);
}
function onPointer(event: PointerEvent) {
if (adding) return;
const target = event.target;
if (target instanceof Node && pickRef.current?.contains(target)) return;
setOpen(null);
}
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointer);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointer);
};
}, [open, adding]);
async function addPerson(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (missingOtherProfileStep(draft)) return;
if (!adding || missingOtherProfileStep(draft)) return;
const slot = adding;
setBusy(true);
try {
const response = await fetch("/api/chart-profiles", {
@@ -91,9 +124,9 @@ export function SynastryPicker() {
setMessage(payload?.error || "保存失败,请重试");
return;
}
setAdding(false);
setDraft(emptyProfile);
await load(payload.profile.id);
setAdding(null);
setDraft(emptySynastryDraft());
await load({ id: payload.profile.id, slot });
} finally {
setBusy(false);
}
@@ -117,6 +150,11 @@ export function SynastryPicker() {
setMessage(payload?.error || "暂时无法生成合盘报告");
return;
}
const pair = {
a: people.find((person) => person.id === left)?.name ?? "",
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}`);
} finally {
setBusy(false);
@@ -132,77 +170,175 @@ export function SynastryPicker() {
);
}
const archiveFull = people.filter((person) => person.role === "other").length >= MAX_SAVED_SUBJECTS - 1;
const ready = Boolean(left && right && left !== right) && !personIncomplete(people, left) && !personIncomplete(people, right);
return (
<>
<SecondaryHeader title="婚恋合盘" />
<div className="synastry-picker">
<p>选两个人,生成一份完整合盘报告。</p>
<p>{creditCost !== null ? `这份完整合盘报告使用 ${creditCost} 点。` : pricingNote}</p>
<PersonMenu label="第一个人" people={people} value={left} onChange={setLeft} />
<PersonMenu label="第二个人" people={people} value={right} onChange={setRight} placeholder="请选择" />
<button type="button" onClick={() => setAdding((open) => !open)}>+ 新增星盘</button>
{adding ? (
<ChartProfileForm
title="新增星盘"
description="称呼和出生资料。填报的出生时间保存后不能再改。"
value={draft}
onChange={setDraft}
nameInputId="synastry-new-person"
showAyanamsa
showGender
onSubmit={(event) => void addPerson(event)}
onCancel={() => setAdding(false)}
submitLabel={busy ? "保存中" : "保存"}
submitDisabled={busy || Boolean(missingOtherProfileStep(draft))}
<div className="synastry-page-heading">
<h1>婚恋合盘</h1>
<p className="synastry-page-sub">看这段感情往后的走向,和两人之间的助力与考验。</p>
</div>
<div className="synastry-pick" ref={pickRef}>
<PersonMenu
label="第一个人"
slot="left"
people={people}
value={left}
otherId={right}
open={open === "left"}
archiveFull={archiveFull}
onToggle={() => setOpen((current) => current === "left" ? null : "left")}
onChange={(id) => { setLeft(id); setOpen(null); }}
onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("left"); }}
/>
) : null}
<button type="button" onClick={() => void createReport()} disabled={busy || personIncomplete(people, left) || personIncomplete(people, right)}>开始合盘</button>
{message ? <p role="status">{message}</p> : null}
<div className="synastry-times" aria-hidden="true">×</div>
<PersonMenu
label="第二个人"
slot="right"
people={people}
value={right}
otherId={left}
open={open === "right"}
archiveFull={archiveFull}
placeholder="选择一个人"
onToggle={() => setOpen((current) => current === "right" ? null : "right")}
onChange={(id) => { setRight(id); setOpen(null); }}
onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("right"); }}
/>
</div>
<div className="synastry-go">
<button type="button" className="button-primary" onClick={() => void createReport()} disabled={busy || !ready}>开始合盘</button>
{creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。</p> : pricingNote ? <p className="synastry-cost">{pricingNote}</p> : null}
{message ? <p className="synastry-cost" role="status">{message}</p> : null}
</div>
{earlier.length > 0 ? (
<section className="synastry-history-list">
<b>之前的合盘</b>
{earlier.map((report) => (
<a key={report.id} className="synastry-history-item" href={`/reports/${report.id}`}>{report.cardSummary || "婚恋合盘"}</a>
))}
<h2>之前的合盘</h2>
<ul>
{earlier.map((report) => (
<li key={report.id}>
<a className="synastry-history-item" href={`/reports/${report.id}`}>
<span className="who">{historyWho(report.cardSummary)}</span>
{report.createdAt ? <span className="meta">{historyWhen(report.createdAt)}</span> : null}
</a>
</li>
))}
</ul>
</section>
) : null}
</div>
{adding ? (
<SynastryAddDialog
value={draft}
busy={busy}
submitDisabled={Boolean(missingOtherProfileStep(draft))}
onChange={setDraft}
onCancel={() => setAdding(null)}
onSubmit={(event) => void addPerson(event)}
/>
) : null}
</>
);
}
function personRow(id: string, role: "self" | "other", profile: Profile): Person {
const gender = parseProfileGender(profile.gender);
const date = effectiveBirthDate(profile);
return {
id,
role,
name: peopleDisplayName(profile, role),
meta: [gender ? PROFILE_GENDER_LABELS[gender] : "", date ? date.slice(0, 7) : "", peopleCityLabel(profile)].filter(Boolean).join(" · "),
incomplete: missingOtherProfileStep(profile) !== null,
};
}
function personIncomplete(people: readonly Person[], id: string): boolean {
if (!id) return true;
return people.find((person) => person.id === id)?.incomplete !== false;
}
function historyWho(summary: string | undefined): string {
const text = (summary || "婚恋合盘").replace(/ · 婚恋合盘$/, "");
return text || "婚恋合盘";
}
function historyWhen(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const day = `${date.getMonth() + 1} 月 ${date.getDate()} 日`;
return date.getFullYear() === new Date().getFullYear() ? day : `${date.getFullYear()} 年 ${day}`;
}
function PersonMenu(props: {
label: string;
slot: Slot;
people: readonly Person[];
value: string;
onChange: (id: string) => void;
otherId: string;
open: boolean;
archiveFull: boolean;
placeholder?: string;
onToggle: () => void;
onChange: (id: string) => void;
onAdd: () => void;
}) {
const selected = props.people.find((person) => person.id === props.value);
const shown = selected && !selected.incomplete ? selected : null;
return (
<label>
{props.label}
<details className="synastry-picker-list">
<summary>{selected && !selected.incomplete ? selected.name : (props.placeholder || "请选择")}</summary>
{props.people.map((person) => (
<button
key={person.id}
type="button"
disabled={person.incomplete}
onClick={(event) => {
props.onChange(person.id);
event.currentTarget.closest("details")?.removeAttribute("open");
}}
>
{person.name}{person.incomplete ? "(资料未齐)" : ""}
</button>
))}
</details>
</label>
<div className="synastry-field">
<span id={`synastry-${props.slot}-label`}>{props.label}</span>
<div className="synastry-dd">
<button
type="button"
className="synastry-dd-btn"
aria-haspopup="listbox"
aria-expanded={props.open}
onClick={props.onToggle}
>
{shown ? (
<span className="synastry-dd-main"><b>{shown.name}</b>{shown.meta ? <span className="ph">{shown.meta}</span> : null}</span>
) : (
<span className="ph">{props.placeholder || "选择一个人"}</span>
)}
</button>
{props.open ? (
<ul className="synastry-dd-list" role="listbox" aria-labelledby={`synastry-${props.slot}-label`}>
{props.people.map((person) => {
const taken = person.id === props.otherId;
const disabled = person.incomplete || taken;
const hint = person.incomplete ? "资料未齐" : taken ? "已在另一边" : person.meta;
return (
<li key={person.id}>
<button
type="button"
role="option"
aria-selected={person.id === props.value}
disabled={disabled}
onClick={() => props.onChange(person.id)}
>
<b>{person.name}</b>
{hint ? <small>{hint}</small> : null}
</button>
</li>
);
})}
{props.archiveFull ? (
<li className="synastry-dd-add is-full">
档案已满(最多 {MAX_SAVED_SUBJECTS} 人)
<a href="/people">去星盘档案删一个</a>
</li>
) : (
<li>
<button type="button" className="synastry-dd-add" onClick={props.onAdd}>+ 新增星盘</button>
</li>
)}
</ul>
) : null}
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { SynastryWait } from "@/components/synastry/synastry-wait";
import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client";
import { SECTION_TITLES, SYNASTRY_ASK_SECTION_IDS, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER, type SynastryAskSectionId } from "@/lib/synastry-full-sections";
import type { TechnicalBlock } from "@/lib/synastry-full-technical";
@@ -27,14 +28,27 @@ type Snapshot = {
english?: LanguageBody;
};
const ZH_NUMBERS = ["一", "二", "三", "四", "五", "六", "七", "八", "九"] as const;
function asSnapshot(value: unknown): Snapshot | null {
if (!value || typeof value !== "object") return null;
return value as Snapshot;
}
function heading(index: number, title: string, language: "zh" | "en") {
return language === "zh" ? `${ZH_NUMBERS[index]}、${title}` : title;
}
function savedLine(createdAt?: string) {
const day = createdAt?.slice(0, 10) ?? "";
const dated = /^\d{4}-\d{2}-\d{2}$/.test(day) ? `${day} · ` : "";
return `${dated}已保存,再次打开不扣点`;
}
export function SynastryReportView(props: {
reportId: string;
snapshot: unknown;
createdAt?: string;
initialLanguage?: "zh" | "en";
}) {
const snapshot = asSnapshot(props.snapshot);
@@ -100,92 +114,102 @@ export function SynastryReportView(props: {
const names = snapshot.names ?? {};
const title = `${names.a || "甲"} × ${names.b || "乙"} · 婚恋合盘`;
if (waiting) {
return (
<div className="personal-report-state" role="status">
<p>正在准备英文版,完成后会直接显示。</p>
</div>
);
}
return (
<article className="synastry-report">
<a className="synastry-report-back" href="/synastry">‹ 婚恋合盘</a>
<header className="synastry-report-heading">
<h1>{title}</h1>
<button type="button" onClick={() => void (language === "en" ? setLanguage("zh") : showEnglish())}>
{language === "en" ? "看中文版" : "看英文版"}
</button>
<div className="synastry-report-meta">
<span>{savedLine(props.createdAt)}</span>
<div className="synastry-lang" role="group" aria-label="语言">
<button type="button" aria-pressed={language === "zh"} onClick={() => setLanguage("zh")}>中文</button>
<button type="button" aria-pressed={language === "en"} onClick={() => showEnglish()}>English</button>
</div>
</div>
</header>
{englishNote ? <p>{englishNote}</p> : null}
{SYNASTRY_PROSE_IDS.map((id) => {
const section = body.sections?.find((item) => item.id === id);
if (!section) return null;
return (
<section key={id}>
<h2>{section.title || SECTION_TITLES[id][language]}</h2>
<p>{section.text}</p>
<details>
<summary>分析逻辑</summary>
<ol>
{(section.reasons ?? []).map((reason, index) => (
<li key={`${id}-${index}`}>
<p>盘面:{reason.basis}</p>
<p>所以:{reason.plain}</p>
</li>
))}
</ol>
</details>
{waiting ? <SynastryWait mode="english" /> : (
<>
{SYNASTRY_PROSE_IDS.map((id, index) => {
const section = body.sections?.find((item) => item.id === id);
if (!section) return null;
return (
<section key={id} className="synastry-section">
<h2>{heading(index, section.title || SECTION_TITLES[id][language], language)}</h2>
<p>{section.text}</p>
{language === "zh" ? (
<details className="synastry-why">
<summary>分析逻辑</summary>
<ol>
{(section.reasons ?? []).map((reason, reasonIndex) => (
<li key={`${id}-${reasonIndex}`}>
<p>盘面:{reason.basis}</p>
<p>所以:{reason.plain}</p>
</li>
))}
</ol>
</details>
) : null}
</section>
);
})}
<section className="synastry-section">
<h2>{heading(7, SECTION_TITLES.timeline[language], language)}</h2>
{language === "zh" ? <p className="lead">把两个人各自走到的人生阶段放在一起看,这段关系在哪段时间会是什么样子。说的是倾向和条件,不是会发生什么的预言。</p> : null}
<ol className="synastry-tl">
{(body.timeline ?? []).map((segment) => (
<li key={segment.id}>
<div className="synastry-tl-h"><b>{segment.phase}</b></div>
<p>{segment.text}</p>
{language === "zh" ? (
<details className="synastry-why">
<summary>分析逻辑</summary>
<p>盘面:{segment.basis}</p>
</details>
) : null}
</li>
))}
</ol>
</section>
);
})}
<section>
<h2>{SECTION_TITLES.timeline[language]}</h2>
<ol>
{(body.timeline ?? []).map((segment) => (
<li key={segment.id}>
<h3>{segment.phase}</h3>
<p>{segment.text}</p>
<details>
<summary>分析逻辑</summary>
<p>盘面:{segment.basis}</p>
</details>
</li>
))}
</ol>
</section>
<section>
<h2>{SECTION_TITLES.practices[language]}</h2>
<ul>
{(body.practices ?? []).map((item) => <li key={item.text}>{item.text}</li>)}
</ul>
</section>
<section>
<h2>可以一起聊聊</h2>
<ul>
{TALK_TOGETHER.map((question) => <li key={question}>{question}</li>)}
</ul>
</section>
<details className="synastry-technical">
<summary>技术依据摘要</summary>
{(snapshot.technical ?? []).map((block) => (
<section key={block.id}>
<h3>{block.title}</h3>
<section className="synastry-section">
<h2>{heading(8, SECTION_TITLES.practices[language], language)}</h2>
<ul>
{block.lines.map((line) => <li key={line}>{line}</li>)}
{(body.practices ?? []).map((item) => <li key={item.text}>{item.text}</li>)}
</ul>
</section>
))}
</details>
<nav className="synastry-report-ask" aria-label="对哪一段想再问清楚?">
<p>对哪一段想再问清楚?</p>
{SYNASTRY_ASK_SECTION_IDS.map((id) => (
<button key={id} type="button" disabled={asking !== ""} onClick={() => void askSection(id)}>
{SECTION_TITLES[id][language]}
</button>
))}
</nav>
{askError ? <p role="alert">{askError}</p> : null}
<p>{SYNASTRY_FOOTNOTE}</p>
{language === "zh" ? (
<section className="synastry-talk">
<h2>可以一起聊聊</h2>
<ul>
{TALK_TOGETHER.map((question) => <li key={question}>{question}</li>)}
</ul>
</section>
) : null}
<nav className="synastry-askbox" aria-label="对哪一段想再问清楚?">
<p>对哪一段想再问清楚?去对话里接着聊,会用印度占星的语言讲背后的依据。</p>
<div className="synastry-report-ask">
{SYNASTRY_ASK_SECTION_IDS.map((id) => (
<button key={id} type="button" disabled={asking !== ""} onClick={() => void askSection(id)}>
{SECTION_TITLES[id][language]}
</button>
))}
</div>
</nav>
{askError ? <p role="alert">{askError}</p> : null}
<details className="synastry-technical">
<summary>{language === "en" ? "Technical basis" : "技术依据摘要"}</summary>
{(snapshot.technical ?? []).map((block) => (
<section key={block.id}>
<h3>{block.title}</h3>
<ul>
{block.lines.map((line) => <li key={line}>{line}</li>)}
</ul>
</section>
))}
</details>
<p className="synastry-footnote">{language === "en" ? "A synastry reading describes the texture of how two people interact. Whether and how you go forward is still your choice." : SYNASTRY_FOOTNOTE}</p>
</>
)}
</article>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useSyncExternalStore } from "react";
const STEPS = ["算两张完整命盘", "整理两人之间的证据", "写报告", "检查报告"] as const;
const NO_PAIR = null;
export function synastryWaitDoneCount(phase: string | undefined): number {
if (phase === "checking_report" || phase === "persisting_report") return 3;
if (phase === "writing_report") return 2;
return 0;
}
const pairCache = new Map<string, { a: string; b: string } | null>();
function pairFromStorage(reportId: string): { a: string; b: string } | null {
const raw = sessionStorage.getItem(`synastry-wait:${reportId}`) ?? "";
const key = `${reportId}:${raw}`;
if (pairCache.has(key)) return pairCache.get(key) ?? null;
let value: { a: string; b: string } | null = null;
try {
const parsed = raw ? JSON.parse(raw) as { a?: unknown; b?: unknown } : null;
if (parsed && typeof parsed.a === "string" && typeof parsed.b === "string") {
const a = parsed.a.trim();
const b = parsed.b.trim();
if (a && b) value = { a, b };
}
} catch {
value = null;
}
pairCache.set(key, value);
return value;
}
export function SynastryWait(props: {
mode?: "report" | "english";
reportId?: string;
phase?: string;
names?: { a?: string; b?: string };
}) {
const stored = useSyncExternalStore(
() => () => {},
() => (props.reportId ? pairFromStorage(props.reportId) : NO_PAIR),
() => NO_PAIR,
);
if (props.mode === "english") {
return (
<div className="synastry-wait" role="status">
<Orbit />
<p>正在写英文版…</p>
<p className="sub">第一次切到英文时才写,不扣点,大约一两分钟。</p>
</div>
);
}
const a = props.names?.a?.trim() || stored?.a || "";
const b = props.names?.b?.trim() || stored?.b || "";
const who = a && b ? ` ${a} × ${b} 的` : "";
const done = synastryWaitDoneCount(props.phase);
return (
<div className="synastry-wait" role="status">
<Orbit />
<p>正在写{who}合盘报告…</p>
<ol className="synastry-steps">
{STEPS.map((label, index) => (
<li key={label} className={index < done ? "done" : undefined}>{label}</li>
))}
</ol>
<p className="sub">大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。</p>
</div>
);
}
function Orbit() {
return (
<div className="synastry-orbit" aria-hidden="true">
<i />
<b />
<b />
</div>
);
}
+23 -1
View File
@@ -117,6 +117,22 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /compatibility === false/);
assert.match(picker, /合盘服务暂未开放/);
assert.ok(picker.indexOf("setServiceClosed(true)") < picker.indexOf("fetchAccount()"));
assert.match(picker, /看这段感情往后的走向,和两人之间的助力与考验。/);
assert.match(picker, /开始合盘/);
assert.match(picker, /+ 新增星盘/);
assert.match(picker, /生成一份完整合盘报告,用掉/);
assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/);
const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8");
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
assert.match(dialog, /称呼/);
assert.match(dialog, /出生日期/);
assert.match(dialog, /出生时间/);
assert.match(dialog, /出生地/);
assert.match(dialog, /保存并选中/);
assert.match(wait, /算两张完整命盘/);
assert.match(wait, /整理两人之间的证据/);
assert.match(wait, /正在写英文版…/);
assert.match(wait, /大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。/);
});
test("old synastry links open the pair page instead of a chat draft", () => {
@@ -141,7 +157,10 @@ test("report view keeps analysis folded and offers a section ask entry", () => {
technical: buildTechnicalBasis({ dossier: {}, names: { a: "甲", b: "乙" }, contacts: [], timing: [] }),
},
}));
assert.match(html, /<details>/);
// 原值:`<details>`
// 新值:`<details class="synastry-why">`
// 原因:分析逻辑仍默认收起,类名用来套原型的收起样式
assert.match(html, /<details class="synastry-why">/);
assert.equal(html.includes("<details open"), false);
// 原值:html 不含「对哪一段想再问清楚」
// 新值:报告末尾有这一句,八节可点,共同练习仍只是标题
@@ -153,6 +172,9 @@ test("report view keeps analysis folded and offers a section ask entry", () => {
assert.match(html, /所以:靠近时先确认对方听完了。/);
assert.match(html, /外部资料与真实案例核对:未进行/);
assert.match(html, /· 婚恋合盘/);
assert.match(html, /‹ 婚恋合盘/);
assert.match(html, /已保存,再次打开不扣点/);
assert.match(html, /可以一起聊聊/);
});
test("section evidence follows reader briefs and keeps relationship_axis", () => {