From 67c1e688155818f20d9322e295cd42cdf9d6ff33 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 20 Oct 2026 17:21:33 +0800 Subject: [PATCH] feat(synastry): lay out the match screens from the confirmed prototype The add-person card, the generating wait, and the report header follow the prototype the product owner pointed at. Co-authored-by: Cursor --- CHANGELOG.md | 4 + frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 2 +- frontend/src/app/globals.css | 319 +++++++++++++++--- .../personal-report/personal-report-page.tsx | 17 +- .../synastry/synastry-add-dialog.tsx | 192 +++++++++++ .../components/synastry/synastry-picker.tsx | 264 +++++++++++---- .../synastry/synastry-report-view.tsx | 176 +++++----- .../src/components/synastry/synastry-wait.tsx | 83 +++++ frontend/tests/synastry-full-report.test.ts | 24 +- 10 files changed, 897 insertions(+), 186 deletions(-) create mode 100644 frontend/src/components/synastry/synastry-add-dialog.tsx create mode 100644 frontend/src/components/synastry/synastry-wait.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccd1f70..f8037875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-20 — 婚恋合盘选人页按已确认原型排版 + +- 选人页、新增星盘、生成中和报告页按已确认原型排版。新增是一张卡片;生成中是轨道和四步;报告有返回、日期和中英文切换。Skill 版本不变。 + ## 2026-10-20 — 后台可以修改并删除已发布模型 - 编辑已发布或已下线的模型会另存成下一版草稿,当前线上版本保持不变。更换供应商模型时,显示名跟着新模型走。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f1eb2ad2..a9bffd04 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,7 +2,7 @@ ## 婚恋合盘报告(2026-10-19) -选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。报告末尾一行「对哪一段想再问清楚?」,下面是八枚描边胶囊按钮(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送,也不出现新的加载动画。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。 +选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。 ## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index dc428a2f..3c0c3e9e 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 吵完之后,怎样递出台阶才不会变成认输? - 过日子的安排里,哪一件需要现在就说清楚? -报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。 +报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。 ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5cfdd299..96452927 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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%; diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 9440e22e..9247c8a6 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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 ; + if (state.phase === "generating" && state.reportType === "synastry_full") { + return ( + <> + + + + ); + } + 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 <> - + ); } diff --git a/frontend/src/components/synastry/synastry-add-dialog.tsx b/frontend/src/components/synastry/synastry-add-dialog.tsx new file mode 100644 index 00000000..07d7e4bc --- /dev/null +++ b/frontend/src/components/synastry/synastry-add-dialog.tsx @@ -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) => 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 ( +
+
event.stopPropagation()} + > +

新增星盘

+
+
+ +
+ 性别 +
+ + +
+
+ + +
+ + {hits.length > 0 && !chosen ? ( +
    + {hits.map((hit) => ( +
  • + +
  • + ))} +
+ ) : null} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index 4fc44d7b..c3dc8832 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -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([]); const [left, setLeft] = useState("self"); const [right, setRight] = useState(""); - const [adding, setAdding] = useState(false); - const [draft, setDraft] = useState(emptyProfile); + const [open, setOpen] = useState(null); + const [adding, setAdding] = useState(null); + const [draft, setDraft] = useState(emptySynastryDraft); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(false); const [creditCost, setCreditCost] = useState(null); const [pricingNote, setPricingNote] = useState(""); const [earlier, setEarlier] = useState([]); const [serviceClosed, setServiceClosed] = useState(false); + const pickRef = useRef(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) { 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 ( <>
-

选两个人,生成一份完整合盘报告。

-

{creditCost !== null ? `这份完整合盘报告使用 ${creditCost} 点。` : pricingNote}

- - - - {adding ? ( - void addPerson(event)} - onCancel={() => setAdding(false)} - submitLabel={busy ? "保存中" : "保存"} - submitDisabled={busy || Boolean(missingOtherProfileStep(draft))} +
+

婚恋合盘

+

看这段感情往后的走向,和两人之间的助力与考验。

+
+
+ setOpen((current) => current === "left" ? null : "left")} + onChange={(id) => { setLeft(id); setOpen(null); }} + onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("left"); }} /> - ) : null} - - {message ?

{message}

: null} + + setOpen((current) => current === "right" ? null : "right")} + onChange={(id) => { setRight(id); setOpen(null); }} + onAdd={() => { setOpen(null); setDraft(emptySynastryDraft()); setAdding("right"); }} + /> +
+
+ + {creditCost !== null ?

生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。

: pricingNote ?

{pricingNote}

: null} + {message ?

{message}

: null} +
{earlier.length > 0 ? (
- 之前的合盘 - {earlier.map((report) => ( - {report.cardSummary || "婚恋合盘"} - ))} +

之前的合盘

+
) : null}
+ {adding ? ( + 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 ( - +
+ {props.label} +
+ + {props.open ? ( +
    + {props.people.map((person) => { + const taken = person.id === props.otherId; + const disabled = person.incomplete || taken; + const hint = person.incomplete ? "资料未齐" : taken ? "已在另一边" : person.meta; + return ( +
  • + +
  • + ); + })} + {props.archiveFull ? ( +
  • + 档案已满(最多 {MAX_SAVED_SUBJECTS} 人) + 去星盘档案删一个 +
  • + ) : ( +
  • + +
  • + )} +
+ ) : null} +
+
); } diff --git a/frontend/src/components/synastry/synastry-report-view.tsx b/frontend/src/components/synastry/synastry-report-view.tsx index a95c2c31..06a39b58 100644 --- a/frontend/src/components/synastry/synastry-report-view.tsx +++ b/frontend/src/components/synastry/synastry-report-view.tsx @@ -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 ( -
-

正在准备英文版,完成后会直接显示。

-
- ); - } - return (
+ ‹ 婚恋合盘

{title}

- +
+ {savedLine(props.createdAt)} +
+ + +
+
{englishNote ?

{englishNote}

: null} - {SYNASTRY_PROSE_IDS.map((id) => { - const section = body.sections?.find((item) => item.id === id); - if (!section) return null; - return ( -
-

{section.title || SECTION_TITLES[id][language]}

-

{section.text}

-
- 分析逻辑 -
    - {(section.reasons ?? []).map((reason, index) => ( -
  1. -

    盘面:{reason.basis}

    -

    所以:{reason.plain}

    -
  2. - ))} -
-
+ {waiting ? : ( + <> + {SYNASTRY_PROSE_IDS.map((id, index) => { + const section = body.sections?.find((item) => item.id === id); + if (!section) return null; + return ( +
+

{heading(index, section.title || SECTION_TITLES[id][language], language)}

+

{section.text}

+ {language === "zh" ? ( +
+ 分析逻辑 +
    + {(section.reasons ?? []).map((reason, reasonIndex) => ( +
  1. +

    盘面:{reason.basis}

    +

    所以:{reason.plain}

    +
  2. + ))} +
+
+ ) : null} +
+ ); + })} +
+

{heading(7, SECTION_TITLES.timeline[language], language)}

+ {language === "zh" ?

把两个人各自走到的人生阶段放在一起看,这段关系在哪段时间会是什么样子。说的是倾向和条件,不是会发生什么的预言。

: null} +
    + {(body.timeline ?? []).map((segment) => ( +
  1. +
    {segment.phase}
    +

    {segment.text}

    + {language === "zh" ? ( +
    + 分析逻辑 +

    盘面:{segment.basis}

    +
    + ) : null} +
  2. + ))} +
- ); - })} -
-

{SECTION_TITLES.timeline[language]}

-
    - {(body.timeline ?? []).map((segment) => ( -
  1. -

    {segment.phase}

    -

    {segment.text}

    -
    - 分析逻辑 -

    盘面:{segment.basis}

    -
    -
  2. - ))} -
-
-
-

{SECTION_TITLES.practices[language]}

-
    - {(body.practices ?? []).map((item) =>
  • {item.text}
  • )} -
-
-
-

可以一起聊聊

-
    - {TALK_TOGETHER.map((question) =>
  • {question}
  • )} -
-
-
- 技术依据摘要 - {(snapshot.technical ?? []).map((block) => ( -
-

{block.title}

+
+

{heading(8, SECTION_TITLES.practices[language], language)}

    - {block.lines.map((line) =>
  • {line}
  • )} + {(body.practices ?? []).map((item) =>
  • {item.text}
  • )}
- ))} -
- - {askError ?

{askError}

: null} -

{SYNASTRY_FOOTNOTE}

+ {language === "zh" ? ( +
+

可以一起聊聊

+
    + {TALK_TOGETHER.map((question) =>
  • {question}
  • )} +
+
+ ) : null} + + {askError ?

{askError}

: null} +
+ {language === "en" ? "Technical basis" : "技术依据摘要"} + {(snapshot.technical ?? []).map((block) => ( +
+

{block.title}

+
    + {block.lines.map((line) =>
  • {line}
  • )} +
+
+ ))} +
+

{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}

+ + )}
); } diff --git a/frontend/src/components/synastry/synastry-wait.tsx b/frontend/src/components/synastry/synastry-wait.tsx new file mode 100644 index 00000000..8f055bbd --- /dev/null +++ b/frontend/src/components/synastry/synastry-wait.tsx @@ -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(); + +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 ( +
+ +

正在写英文版…

+

第一次切到英文时才写,不扣点,大约一两分钟。

+
+ ); + } + + 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 ( +
+ +

正在写{who}合盘报告…

+
    + {STEPS.map((label, index) => ( +
  1. {label}
  2. + ))} +
+

大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。

+
+ ); +} + +function Orbit() { + return ( + + ); +} diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 33025a40..25dd135d 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -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, /
/); + // 原值:`
` + // 新值:`
` + // 原因:分析逻辑仍默认收起,类名用来套原型的收起样式 + assert.match(html, /
/); assert.equal(html.includes("
{ 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", () => {