feat(report): replace the reader edition with the full-data report

Generate the full-data edition (pl9_personal_long_report.v4) and remove the reader edition. English still contains Han, so the API withholds it (BUG-1272, investigating). This commit is not pushed to staging.
This commit is contained in:
jesse-ux
2026-10-08 22:29:31 +08:00
committed by Jesse_Chen
parent db0134f9db
commit 98c98aa535
32 changed files with 4039 additions and 133 deletions
+26
View File
@@ -4298,6 +4298,32 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (max-width: 767px) {
.report-edge-jump { right: var(--space-4); bottom: calc(var(--space-4) + env(safe-area-inset-bottom)); }
}
.personal-report-chapter-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-5);
}
.personal-report-chapter-pager button {
min-height: 44px;
padding: 0 var(--space-4);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-canvas);
color: var(--color-ink);
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
}
.personal-report-chapter-pager button:disabled {
opacity: 0.45;
cursor: default;
}
.personal-report-chapter-pager span {
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.personal-report-toc-drawer { display: none; }
.personal-report-toc-desktop p {
margin: 0 0 var(--space-2);
@@ -214,7 +214,7 @@ export function PersonalReportCenter() {
{/* The title says what you get, the button says what it does (2026-09-29):
「生成个人报告」 over a 「生成报告」 button said the same thing twice. */}
<h2 id="report-center-create-title">完整本命报告</h2>
<p>星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。</p>
<p>星盘、力量、大运、年运和瑜伽的完整数据,中英两版;生成后可离开,完成时下方自动出现。</p>
<GeneratePersonalReportButton onCreated={() => void load()} />
</section>
<section className="report-center-section">
@@ -107,9 +107,9 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[],
}
/**
* 「到底部」 has to mount every lazy section before it scrolls. The flag lives
* outside React state so the article view itself stays state-free (its tree is
* memoized and must not re-render as a whole); only the sections subscribe.
* 「到底部」 has to mount every lazy section before it scrolls. Scroll position
* stays outside this view. The only state here is the chapter index and the
* print flag; markdown trees stay in useMemo.
*/
type RevealSignal = { revealed: boolean; listeners: Set<() => void> };
@@ -163,9 +163,23 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
);
});
function ReportToc({ headings, language }: { headings: readonly LongformHeading[]; language: ReportLanguage }) {
/** Above this size the reader shows one chapter at a time. Shorter reports keep lazy sections. */
export const FULL_DATA_CHAPTER_PAGE_CHARS = 200_000;
function ReportToc({
headings,
language,
onSelect,
activeHeadingId,
}: {
headings: readonly LongformHeading[];
language: ReportLanguage;
onSelect?: (id: string) => void;
activeHeadingId?: string;
}) {
const label = language === "en" ? "Contents" : "目录";
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
const [observedId, setObservedId] = useState(headings[0]?.id ?? "");
const activeId = activeHeadingId || observedId;
useEffect(() => {
if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
@@ -174,7 +188,7 @@ function ReportToc({ headings, language }: { headings: readonly LongformHeading[
.filter((entry) => entry.isIntersecting)
.sort((left, right) => left.boundingClientRect.top - right.boundingClientRect.top);
const id = visible[0]?.target.id;
if (id) setActiveId(id);
if (id) setObservedId(id);
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
for (const item of headings) {
const node = document.getElementById(item.id);
@@ -187,11 +201,11 @@ function ReportToc({ headings, language }: { headings: readonly LongformHeading[
<nav aria-label={language === "en" ? "Report contents" : "报告目录"} className="personal-report-toc">
<details className="personal-report-toc-drawer">
<summary>{label}</summary>
<TocList activeId={activeId} items={headings} />
<TocList activeId={activeId} items={headings} onSelect={onSelect} />
</details>
<div className="personal-report-toc-desktop">
<p>{label}</p>
<TocList activeId={activeId} items={headings} />
<TocList activeId={activeId} items={headings} onSelect={onSelect} />
</div>
</nav>
);
@@ -204,17 +218,92 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
drawnThrough?: number | null;
}) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]);
const paged = markdown.length > FULL_DATA_CHAPTER_PAGE_CHARS && chapters.length > 1;
const [chapter, setChapter] = useState(() => {
if (drawnThrough === null) return 0;
return Math.min(Math.max(drawnThrough, 0), Math.max(chapters.length - 1, 0));
});
const [printing, setPrinting] = useState(false);
const reveal = useMemo<RevealSignal>(() => ({ revealed: false, listeners: new Set() }), []);
useEffect(() => {
if (!paged) return;
const before = () => setPrinting(true);
const after = () => setPrinting(false);
window.addEventListener("beforeprint", before);
window.addEventListener("afterprint", after);
return () => {
window.removeEventListener("beforeprint", before);
window.removeEventListener("afterprint", after);
};
}, [paged]);
// Synchronous on purpose: the caller scrolls to scrollHeight right after, and
// that height is only final once every lazy section has committed.
const revealAll = useCallback(() => flushSync(() => {
if (paged) {
setChapter(Math.max(chapters.length - 1, 0));
return;
}
reveal.revealed = true;
for (const listener of reveal.listeners) listener();
}), [reveal]);
}), [chapters.length, paged, reveal]);
const lead = useMemo(
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language) : null),
[outline, language],
);
const safeChapter = Math.min(chapter, Math.max(chapters.length - 1, 0));
const currentChapter = chapters[safeChapter];
const pagedChapter = useMemo(() => (
paged && currentChapter
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language)
: null
), [paged, currentChapter, language]);
const printedChapters = useMemo(() => (
paged && printing
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language))
: null
), [paged, printing, chapters, language]);
const openChapter = useCallback((id: string) => {
const index = chapters.findIndex((section) => section.id === id || section.headings.some((heading) => heading.id === id));
if (index >= 0) setChapter(index);
}, [chapters]);
if (paged) {
return (
<div className="personal-report-md-layout">
<ReportToc
activeHeadingId={currentChapter?.id}
headings={outline.headings}
language={language}
onSelect={openChapter}
/>
<article className="personal-report-document personal-report-md-article" data-chapter-index={safeChapter}>
{safeChapter === 0 && lead ? (
<section className="personal-report-md-lead">
{lead}
</section>
) : null}
{printing
? chapters.map((section, index) => (
<section className="personal-report-md-section" id={section.id} key={section.id}>
{printedChapters?.[index]}
</section>
))
: currentChapter ? (
<section className="personal-report-md-section" id={currentChapter.id}>
{pagedChapter}
</section>
) : null}
<nav aria-label="章节" className="personal-report-chapter-pager personal-report-screen-only">
<button type="button" disabled={safeChapter <= 0} onClick={() => setChapter(safeChapter - 1)}>上一章</button>
<span>{safeChapter + 1} / {chapters.length}</span>
<button type="button" disabled={safeChapter >= chapters.length - 1} onClick={() => setChapter(safeChapter + 1)}>下一章</button>
</nav>
</article>
<ReportEdgeJump revealAll={revealAll} />
</div>
);
}
return (
<div className="personal-report-md-layout">
@@ -226,10 +315,10 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
</section>
) : null}
{/* Each section is one chapter heading, in the same order as the article's h2s. */}
{outline.sections.map((section, chapter) => (section.eager ? null : (
{outline.sections.map((section, index) => (section.eager ? null : (
<LazyMarkdownSection
key={section.id}
drawn={drawnThrough !== null && chapter <= drawnThrough}
drawn={drawnThrough !== null && index <= drawnThrough}
language={language}
reveal={reveal}
section={section}
@@ -244,9 +333,11 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
function TocList({
items,
activeId,
onSelect,
}: {
items: readonly LongformHeading[];
activeId: string;
onSelect?: (id: string) => void;
}) {
return (
<ol>
@@ -256,6 +347,10 @@ function TocList({
aria-current={item.id === activeId ? "location" : undefined}
className={item.id === activeId ? "is-current" : undefined}
href={`#${item.id}`}
onClick={onSelect ? (event) => {
event.preventDefault();
onSelect(item.id);
} : undefined}
>
{item.title}
</a>
@@ -218,6 +218,11 @@ function readEnglishEdition(json: Record<string, unknown>): Pick<
/** Index of the chapter heading at or above the top of the reader, or null at the very top. */
function currentChapterIndex(): number | null {
const paged = document.querySelector<HTMLElement>(".personal-report-md-article[data-chapter-index]");
if (paged) {
const index = Number(paged.dataset.chapterIndex);
return Number.isFinite(index) ? index : null;
}
const reader = document.querySelector<HTMLElement>(".personal-report-reader");
if (!reader || reader.scrollTop < 40) return null;
const top = reader.getBoundingClientRect().top + 80;
@@ -282,6 +287,11 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
setDrawnThrough(chapter);
if (chapter !== null) {
requestAnimationFrame(() => requestAnimationFrame(() => {
const article = document.querySelector<HTMLElement>(".personal-report-md-article[data-chapter-index]");
if (article) {
article.scrollIntoView({ block: "start" });
return;
}
document.querySelectorAll<HTMLElement>(".personal-report-md-article h2")[chapter]?.scrollIntoView({ block: "start" });
}));
}
@@ -151,7 +151,7 @@ async function fetchLongformMarkdown(input: Readonly<{
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...input.payload, include_fact_tables: true, edition: "reader_main", languages: ["zh", "en"] }),
body: JSON.stringify({ ...input.payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"] }),
cache: "no-store",
signal,
});