报告开头改成重点摘要卡
新报告用围栏画出大运和事业、财富、婚恋,标题下写名字与日期。旧报告不变。 自测:pytest test_report_full_data_fix.py 10 通过;相关四份报告测试退出码 0;前端相关测试 147 通过、0 失败、0 取消;tsc 退出码 0;lint 0 error。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
963276ca2f
commit
8883f8475f
@@ -4338,9 +4338,79 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-8) var(--space-7) var(--space-10);
|
||||
}
|
||||
/* The report title (markdown `#`) is the md view's cover line; it takes the
|
||||
heading face like the document view's `.personal-report-cover h1`. */
|
||||
.personal-report-md-article h1 { font-family: var(--font-display); }
|
||||
.personal-report-md-article h1 {
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-display-md);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.personal-report-md-byline {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.personal-report-focus-card {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0 0 var(--space-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.personal-report-focus-kicker {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-overline);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.personal-report-focus-periods,
|
||||
.personal-report-focus-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.personal-report-focus-period {
|
||||
min-width: 0;
|
||||
flex: 1 1 8rem;
|
||||
margin: 0;
|
||||
}
|
||||
.personal-report-focus-period strong { display: block; font-weight: 600; }
|
||||
.personal-report-focus-period span {
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.personal-report-focus-domains { display: grid; gap: var(--space-3); }
|
||||
.personal-report-focus-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.personal-report-focus-row p { margin: 0; }
|
||||
.personal-report-focus-domain { display: block; font-weight: 600; }
|
||||
.personal-report-focus-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas-muted);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-overline);
|
||||
font-weight: 600;
|
||||
}
|
||||
.personal-report-focus-caveat {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.personal-report-md-article h2,
|
||||
.personal-report-md-article h3 { scroll-margin-top: 80px; }
|
||||
.personal-report-md-article h2 {
|
||||
|
||||
@@ -6,11 +6,13 @@ import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
|
||||
import { ReportFocusCard } from "@/components/personal-report/report-focus-card";
|
||||
import {
|
||||
chartAriaLabel,
|
||||
parseReportChartBlock,
|
||||
toNorthIndianChart,
|
||||
} from "@/lib/report-chart-block";
|
||||
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
|
||||
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import { ReportEdgeJump } from "@/components/personal-report/report-edge-jump";
|
||||
@@ -35,8 +37,14 @@ function reportUrlTransform(url: string): string {
|
||||
return url.startsWith("#") ? url : "";
|
||||
}
|
||||
|
||||
function markdownComponents(headings: readonly LongformHeading[], language: ReportLanguage): Components {
|
||||
function markdownComponents(
|
||||
headings: readonly LongformHeading[],
|
||||
language: ReportLanguage,
|
||||
subjectName?: string,
|
||||
createdAt?: string,
|
||||
): Components {
|
||||
const cursor = { h2: 0, h3: 0 };
|
||||
const byline = { shown: false };
|
||||
const nextId = (level: 2 | 3, children: ReactNode) => {
|
||||
const title = flattenText(children).trim();
|
||||
const key = level === 2 ? "h2" : "h3";
|
||||
@@ -58,6 +66,17 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
h1: ({ children }) => {
|
||||
const title = flattenText(children).trim();
|
||||
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
|
||||
byline.shown = true;
|
||||
return (
|
||||
<>
|
||||
<h1>{children}</h1>
|
||||
{line ? <p className="personal-report-md-byline">{line}</p> : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
|
||||
h4: ({ children }) => {
|
||||
@@ -70,7 +89,15 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => {
|
||||
if (fencedLanguage(children) !== "jyotish-chart") {
|
||||
const fence = fencedLanguage(children);
|
||||
if (fence === "jyotish-focus") {
|
||||
const focus = parseReportFocusBlock(fencedSource(children).trim());
|
||||
if (!focus) {
|
||||
return <p className="personal-report-chart-invalid">{language === "en" ? "Focus data is invalid" : "重点数据无效"}</p>;
|
||||
}
|
||||
return <ReportFocusCard block={focus} language={language} />;
|
||||
}
|
||||
if (fence !== "jyotish-chart") {
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
const block = parseReportChartBlock(fencedSource(children).trim());
|
||||
@@ -90,10 +117,16 @@ function markdownComponents(headings: readonly LongformHeading[], language: Repo
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string, headings: readonly LongformHeading[], language: ReportLanguage) {
|
||||
function renderMarkdown(
|
||||
markdown: string,
|
||||
headings: readonly LongformHeading[],
|
||||
language: ReportLanguage,
|
||||
subjectName?: string,
|
||||
createdAt?: string,
|
||||
) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={markdownComponents(headings, language)}
|
||||
components={markdownComponents(headings, language, subjectName, createdAt)}
|
||||
disallowedElements={["script", "iframe", "object", "embed", "img"]}
|
||||
rehypePlugins={[reportChartGrid]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@@ -125,12 +158,16 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
reveal,
|
||||
language,
|
||||
drawn = false,
|
||||
subjectName,
|
||||
createdAt,
|
||||
}: {
|
||||
section: LongformSection;
|
||||
reveal: RevealSignal;
|
||||
language: ReportLanguage;
|
||||
/** Drawn from the first render: the reader is about to jump to it or past it. */
|
||||
drawn?: boolean;
|
||||
subjectName?: string;
|
||||
createdAt?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [seen, setVisible] = useState(section.eager || drawn);
|
||||
@@ -151,10 +188,10 @@ const LazyMarkdownSection = memo(function LazyMarkdownSection({
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
const content = useMemo(
|
||||
() => (visible ? renderMarkdown(section.markdown, section.headings, language) : (
|
||||
() => (visible ? renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt) : (
|
||||
<h2 id={section.id}>{section.title}</h2>
|
||||
)),
|
||||
[visible, section, language],
|
||||
[visible, section, language, subjectName, createdAt],
|
||||
);
|
||||
return (
|
||||
<section ref={ref} className="personal-report-md-section">
|
||||
@@ -211,11 +248,20 @@ function ReportToc({
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThrough = null }: {
|
||||
export function PersonalReportMarkdownView({
|
||||
markdown,
|
||||
language = "zh",
|
||||
drawnThrough = null,
|
||||
subjectName,
|
||||
createdAt,
|
||||
}: {
|
||||
markdown: string;
|
||||
language?: ReportLanguage;
|
||||
/** Chapter index (h2 order) through which sections render immediately; used when switching editions. */
|
||||
drawnThrough?: number | null;
|
||||
/** Owner-only display name. Omitted from export and from the public projection. */
|
||||
subjectName?: string;
|
||||
createdAt?: string;
|
||||
}) {
|
||||
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
|
||||
const chapters = useMemo(() => outline.sections.filter((section) => !section.eager), [outline]);
|
||||
@@ -248,21 +294,21 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
|
||||
for (const listener of reveal.listeners) listener();
|
||||
}), [chapters.length, paged, reveal]);
|
||||
const lead = useMemo(
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language) : null),
|
||||
[outline, language],
|
||||
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings, language, subjectName, createdAt) : null),
|
||||
[outline, language, subjectName, createdAt],
|
||||
);
|
||||
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)
|
||||
? renderMarkdown(currentChapter.markdown, currentChapter.headings, language, subjectName, createdAt)
|
||||
: null
|
||||
), [paged, currentChapter, language]);
|
||||
), [paged, currentChapter, language, subjectName, createdAt]);
|
||||
const printedChapters = useMemo(() => (
|
||||
paged && printing
|
||||
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language))
|
||||
? chapters.map((section) => renderMarkdown(section.markdown, section.headings, language, subjectName, createdAt))
|
||||
: null
|
||||
), [paged, printing, chapters, language]);
|
||||
), [paged, printing, chapters, language, subjectName, createdAt]);
|
||||
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);
|
||||
@@ -318,10 +364,12 @@ export function PersonalReportMarkdownView({ markdown, language = "zh", drawnThr
|
||||
{outline.sections.map((section, index) => (section.eager ? null : (
|
||||
<LazyMarkdownSection
|
||||
key={section.id}
|
||||
createdAt={createdAt}
|
||||
drawn={drawnThrough !== null && index <= drawnThrough}
|
||||
language={language}
|
||||
reveal={reveal}
|
||||
section={section}
|
||||
subjectName={subjectName}
|
||||
/>
|
||||
)))}
|
||||
</article>
|
||||
|
||||
@@ -61,6 +61,8 @@ export type ReportLoadState =
|
||||
markdown: string;
|
||||
reportId: string;
|
||||
createdAt: string;
|
||||
/** Signed-in reader's display name. Never written into the export. */
|
||||
subjectDisplayName?: string;
|
||||
factTables?: ReportFactTable[];
|
||||
calculationCharts?: ReportDocumentV2["charts"];
|
||||
/** `legacy`: generated before the English edition existed; no switch is shown. */
|
||||
@@ -145,6 +147,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
const facts = reportFactTablesSchema.safeParse(json.factTables);
|
||||
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
|
||||
const english = readEnglishEdition(json);
|
||||
const subjectDisplayName = readSubjectDisplayName(json);
|
||||
return {
|
||||
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
|
||||
...(charts.success ? { calculationCharts: charts.data } : {}),
|
||||
@@ -153,6 +156,7 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
markdown,
|
||||
reportId: typeof view.id === "string" ? view.id : "",
|
||||
createdAt: typeof view.createdAt === "string" ? view.createdAt : "",
|
||||
...(subjectDisplayName ? { subjectDisplayName } : {}),
|
||||
};
|
||||
}
|
||||
return { phase: "legacy-unavailable" };
|
||||
@@ -195,6 +199,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readSubjectDisplayName(json: unknown): string | undefined {
|
||||
if (!isRecord(json) || typeof json.subjectDisplayName !== "string") return undefined;
|
||||
const trimmed = json.subjectDisplayName.trim();
|
||||
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The English edition is trusted only in the shape the route promises and only
|
||||
* when it holds no Han character; anything else reads as unavailable, and the
|
||||
@@ -575,7 +586,14 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
|
||||
/>
|
||||
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
|
||||
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */}
|
||||
<PersonalReportMarkdownView key={`markdown-${shown}`} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
|
||||
<PersonalReportMarkdownView
|
||||
key={`markdown-${shown}`}
|
||||
createdAt={state.createdAt}
|
||||
drawnThrough={drawnThrough}
|
||||
language={shown}
|
||||
markdown={markdown}
|
||||
subjectName={state.subjectDisplayName}
|
||||
/>
|
||||
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
|
||||
{factTables && <ReportFactTables key={`facts-${shown}`} language={shown} tables={factTables} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
periodRange,
|
||||
planetLabel,
|
||||
type ReportFocusBlock,
|
||||
} from "@/lib/report-focus-block";
|
||||
import type { ReportLanguage } from "@/lib/report-language";
|
||||
|
||||
const LEVEL = {
|
||||
md: { zh: "主运", en: "Major" },
|
||||
ad: { zh: "子运", en: "Sub" },
|
||||
pd: { zh: "次子运", en: "Sub-sub" },
|
||||
} as const;
|
||||
|
||||
const DOMAIN = {
|
||||
career: { zh: "事业", en: "Career" },
|
||||
wealth: { zh: "财富", en: "Wealth" },
|
||||
relationship: { zh: "婚恋", en: "Relationship" },
|
||||
} as const;
|
||||
|
||||
export function ReportFocusCard({
|
||||
block,
|
||||
language,
|
||||
}: {
|
||||
block: ReportFocusBlock;
|
||||
language: ReportLanguage;
|
||||
}) {
|
||||
const en = language === "en";
|
||||
return (
|
||||
<section className="personal-report-focus-card">
|
||||
<p className="personal-report-focus-kicker">{en ? "Current period" : "现在走的大运"}</p>
|
||||
<div className="personal-report-focus-periods">
|
||||
{block.periods.map((period) => {
|
||||
const range = periodRange(period.start, period.end);
|
||||
return (
|
||||
<p className="personal-report-focus-period" key={period.level}>
|
||||
<strong>{LEVEL[period.level][language]} · {planetLabel(period.lord, language)}</strong>
|
||||
{range ? <span>{range}</span> : null}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="personal-report-focus-domains">
|
||||
{block.domains.map((domain) => {
|
||||
const name = planetLabel(domain.lord, language);
|
||||
const placed = domain.placed === null
|
||||
? (en ? "house not listed" : "落宫未列出")
|
||||
: (en ? `house ${domain.placed}` : `第${domain.placed}宫`);
|
||||
const facts = en
|
||||
? `House ${domain.house} lord · ${name} · ${placed}`
|
||||
: domain.placed === null
|
||||
? `第${domain.house}宫主 · ${name} · ${placed}`
|
||||
: `第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
|
||||
return (
|
||||
<div className="personal-report-focus-row" key={domain.key}>
|
||||
<p>
|
||||
<span className="personal-report-focus-domain">{DOMAIN[domain.key][language]}</span>
|
||||
<span>{facts}</span>
|
||||
</p>
|
||||
<p className="personal-report-focus-tags">
|
||||
<span className="personal-report-focus-tag">{domain.dignity}</span>
|
||||
<span className="personal-report-focus-tag">{domain.role}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="personal-report-focus-caveat">{block.caveat}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { stripReportChartBlocks } from "./report-chart-block";
|
||||
import { renderReportFocusFences } from "./report-focus-block";
|
||||
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
|
||||
|
||||
/** Historical ordinary export contract, retained as the full-body byte regression lock. */
|
||||
export function ordinaryReportDownloadMarkdown(markdown: string): string {
|
||||
return stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown));
|
||||
return renderReportFocusFences(stripReportChartBlocks(projectOrdinaryReportMarkdown(markdown)));
|
||||
}
|
||||
|
||||
@@ -147,6 +147,18 @@ export function reportListTimestamp(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Display name for the signed-in reader. Birth fields stay off this response. */
|
||||
export function readOwnedSubjectDisplayName(document: unknown): string | undefined {
|
||||
if (!document || typeof document !== "object") return undefined;
|
||||
const subject = (document as { subject?: unknown }).subject;
|
||||
if (!subject || typeof subject !== "object") return undefined;
|
||||
const name = (subject as { displayName?: unknown }).displayName;
|
||||
if (typeof name !== "string") return undefined;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed.length > 120 || /[\r\n]/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function reportView(
|
||||
row: PersonalReportRecord,
|
||||
job?: PersonalReportJobRecord | null,
|
||||
@@ -753,6 +765,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
: undefined);
|
||||
if (typeof markdown === "string" && markdown.trim()) {
|
||||
const validated = deps.validateReadyDocument(row.reportDocument);
|
||||
const subjectDisplayName = readOwnedSubjectDisplayName(validated.ok ? validated.document : undefined);
|
||||
// A failed English read never fails the Chinese report; it reads as unavailable.
|
||||
const english: EnglishEditionRead = deps.loadLongformEnglish
|
||||
? await deps.loadLongformEnglish({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
|
||||
@@ -767,6 +780,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
|
||||
factTablesEn: english.factTables,
|
||||
} : {}),
|
||||
...(subjectDisplayName ? { subjectDisplayName } : {}),
|
||||
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
|
||||
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
|
||||
? { calculationCharts: validated.document.charts } : {}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReportDocumentV2 } from "./personal-report-contract";
|
||||
import { reportChartExportSvg } from "./report-chart-export";
|
||||
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
|
||||
import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
|
||||
import { renderReportFocusFences } from "./report-focus-block";
|
||||
import type { ReportFactTable } from "./report-fact-table-schema";
|
||||
import { factCellText, factStatusLabel } from "./report-fact-table-display";
|
||||
import type { ReportLanguage } from "./report-language";
|
||||
@@ -67,9 +68,10 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
|
||||
}
|
||||
// sourcePreamble/sourceMarkdown are the outline's lossless counterparts. The reader's
|
||||
// leadMarkdown also contains lifted summaries: using it here duplicates/reorders prose.
|
||||
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", stripReportChartBlocks(outline.sourcePreamble));
|
||||
const publishBody = (text: string) => renderReportFocusFences(stripReportChartBlocks(text), language);
|
||||
if (outline.sourcePreamble) add("body:lead", "body", language === "en" ? "Summary" : "摘要", publishBody(outline.sourcePreamble));
|
||||
for (const section of outline.sections) {
|
||||
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
|
||||
add(`body:${section.id}`, "body", section.title, publishBody(section.sourceMarkdown));
|
||||
}
|
||||
const chartIds = fencedReportChartIds(projected);
|
||||
for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ReportLanguage } from "./report-language";
|
||||
|
||||
const PLANET_ZH: Record<string, string> = {
|
||||
Sun: "太阳",
|
||||
Moon: "月亮",
|
||||
Mars: "火星",
|
||||
Mercury: "水星",
|
||||
Jupiter: "木星",
|
||||
Venus: "金星",
|
||||
Saturn: "土星",
|
||||
Rahu: "北交点",
|
||||
Ketu: "南交点",
|
||||
};
|
||||
|
||||
const LEVEL_LABEL = {
|
||||
md: { zh: "主运", en: "Major" },
|
||||
ad: { zh: "子运", en: "Sub" },
|
||||
pd: { zh: "次子运", en: "Sub-sub" },
|
||||
} as const;
|
||||
|
||||
const DOMAIN_LABEL = {
|
||||
career: { zh: "事业", en: "Career" },
|
||||
wealth: { zh: "财富", en: "Wealth" },
|
||||
relationship: { zh: "婚恋", en: "Relationship" },
|
||||
} as const;
|
||||
|
||||
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable();
|
||||
const lord = z.string().min(1).max(40).nullable();
|
||||
|
||||
export const reportFocusBlockSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
periods: z.tuple([
|
||||
z.strictObject({ level: z.literal("md"), lord, start: day, end: day }),
|
||||
z.strictObject({ level: z.literal("ad"), lord, start: day, end: day }),
|
||||
z.strictObject({ level: z.literal("pd"), lord, start: day, end: day }),
|
||||
]),
|
||||
domains: z.tuple([
|
||||
z.strictObject({
|
||||
key: z.literal("career"),
|
||||
house: z.literal(10),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
z.strictObject({
|
||||
key: z.literal("wealth"),
|
||||
house: z.literal(2),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
z.strictObject({
|
||||
key: z.literal("relationship"),
|
||||
house: z.literal(7),
|
||||
lord,
|
||||
placed: z.number().int().min(1).max(12).nullable(),
|
||||
dignity: z.string().min(1).max(80),
|
||||
role: z.string().min(1).max(80),
|
||||
}),
|
||||
]),
|
||||
caveat: z.string().min(1).max(240),
|
||||
});
|
||||
|
||||
export type ReportFocusBlock = z.infer<typeof reportFocusBlockSchema>;
|
||||
|
||||
export function parseReportFocusBlock(source: string): ReportFocusBlock | null {
|
||||
try {
|
||||
return reportFocusBlockSchema.parse(JSON.parse(source));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function planetLabel(name: string | null, language: ReportLanguage): string {
|
||||
if (!name) return language === "en" ? "not listed" : "未列出";
|
||||
if (language === "zh") return PLANET_ZH[name] ?? name;
|
||||
return name;
|
||||
}
|
||||
|
||||
function monthLabel(iso: string | null): string | null {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
|
||||
}
|
||||
|
||||
export function periodRange(start: string | null, end: string | null): string | null {
|
||||
const from = monthLabel(start);
|
||||
const to = monthLabel(end);
|
||||
if (from && to) return `${from} – ${to}`;
|
||||
return from ?? to;
|
||||
}
|
||||
|
||||
export function focusBlockText(block: ReportFocusBlock, language: ReportLanguage = "zh"): string {
|
||||
const kicker = language === "en" ? "Current period" : "现在走的大运";
|
||||
const periods = block.periods.map((period) => {
|
||||
const label = LEVEL_LABEL[period.level][language];
|
||||
const range = periodRange(period.start, period.end);
|
||||
const name = planetLabel(period.lord, language);
|
||||
return range ? `${label} ${name} ${range}` : `${label} ${name}`;
|
||||
});
|
||||
const domains = block.domains.map((domain) => {
|
||||
const label = DOMAIN_LABEL[domain.key][language];
|
||||
const name = planetLabel(domain.lord, language);
|
||||
const placed = domain.placed === null
|
||||
? (language === "en" ? "house not listed" : "落宫未列出")
|
||||
: (language === "en" ? `house ${domain.placed}` : `第${domain.placed}宫`);
|
||||
const where = language === "en"
|
||||
? `${label}: House ${domain.house} lord · ${name} · ${placed}`
|
||||
: domain.placed === null
|
||||
? `${label}:第${domain.house}宫主 · ${name} · ${placed}`
|
||||
: `${label}:第${domain.house}宫主 · ${name} · 落第${domain.placed}宫`;
|
||||
return `${where}(${domain.dignity};${domain.role})`;
|
||||
});
|
||||
return [kicker, ...periods, ...domains, block.caveat].join("\n");
|
||||
}
|
||||
|
||||
const FOCUS_FENCE_RE = /```jyotish-focus\r?\n([\s\S]*?)```/g;
|
||||
|
||||
/** Turn the fence into readable lines. A bad fence becomes the same invalid sentence as the page. */
|
||||
export function renderReportFocusFences(markdown: string, language: ReportLanguage = "zh"): string {
|
||||
return markdown.replace(FOCUS_FENCE_RE, (_full, source: string) => {
|
||||
const block = parseReportFocusBlock(String(source).trim());
|
||||
if (!block) return language === "en" ? "Focus data is invalid\n" : "重点数据无效\n";
|
||||
return `${focusBlockText(block, language)}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
export function reportTitleByline(input: {
|
||||
title: string;
|
||||
subjectName?: string | null;
|
||||
createdAt?: string | null;
|
||||
language?: ReportLanguage;
|
||||
}): string | null {
|
||||
const language = input.language ?? "zh";
|
||||
const name = (input.subjectName ?? "").trim();
|
||||
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec((input.createdAt ?? "").trim());
|
||||
const date = dateMatch && validDay(dateMatch[1]) ? dateMatch[1] : "";
|
||||
const showName = name.length > 0 && !input.title.includes(name);
|
||||
if (!showName && !date) return null;
|
||||
if (language === "en") {
|
||||
if (showName && date) return `${name} · Generated ${date}`;
|
||||
if (showName) return name;
|
||||
return `Generated ${date}`;
|
||||
}
|
||||
if (showName && date) return `${name} · 生成于 ${date}`;
|
||||
if (showName) return name;
|
||||
return `生成于 ${date}`;
|
||||
}
|
||||
|
||||
function validDay(iso: string): boolean {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
if (!year || !month || !day) return false;
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -417,7 +417,7 @@ function analyzeOrdinaryReportMarkdown(markdown: string, scope: ProjectionScope
|
||||
}
|
||||
|
||||
if (block.type === "fence") {
|
||||
if (block.lang === "jyotish-chart" && fenceIsPublic(block.raw)) {
|
||||
if ((block.lang === "jyotish-chart" || block.lang === "jyotish-focus") && fenceIsPublic(block.raw)) {
|
||||
kept.push(block.raw);
|
||||
continue;
|
||||
}
|
||||
@@ -497,7 +497,7 @@ function projectProseLine(line: string, scope: ProjectionScope = "report"): { ke
|
||||
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
|
||||
export function sanitizeRawAppendixMarkdown(markdown: string): string {
|
||||
return parseBlocks(markdown).map((block) => {
|
||||
if (block.type === "fence" && block.lang === "jyotish-chart") {
|
||||
if (block.type === "fence" && (block.lang === "jyotish-chart" || block.lang === "jyotish-focus")) {
|
||||
return fenceIsPublic(block.raw) ? block.raw : "";
|
||||
}
|
||||
return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => (
|
||||
|
||||
Reference in New Issue
Block a user