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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user