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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-20 — 婚恋合盘选人页按已确认原型排版
|
||||
|
||||
- 选人页、新增星盘、生成中和报告页按已确认原型排版。新增是一张卡片;生成中是轨道和四步;报告有返回、日期和中英文切换。Skill 版本不变。
|
||||
|
||||
## 2026-10-20 — 后台可以修改并删除已发布模型
|
||||
|
||||
- 编辑已发布或已下线的模型会另存成下一版草稿,当前线上版本保持不变。更换供应商模型时,显示名跟着新模型走。
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## 婚恋合盘报告(2026-10-19)
|
||||
|
||||
选人页用自定义列表,最多约二十人,列表本身可滚动。资料不全的人灰掉,不能选。标题和按钮同一行两端对齐。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。报告末尾一行「对哪一段想再问清楚?」,下面是八枚描边胶囊按钮(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送,也不出现新的加载动画。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
|
||||
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。
|
||||
|
||||
## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 吵完之后,怎样递出台阶才不会变成认输?
|
||||
- 过日子的安排里,哪一件需要现在就说清楚?
|
||||
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页按钮是「开始合盘」。点数从配置读,没有配置时说「计费配置不可用」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。等待时分别说「正在排两人的星盘」「正在写关系画像」「正在核对表述」。
|
||||
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
|
||||
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
|
||||
+278
-41
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user