Files
Jyotisha/frontend/src/lib/report-focus-block.ts
T
jesse-uxandGrok (agent loop) 8883f8475f 报告开头改成重点摘要卡
新报告用围栏画出大运和事业、财富、婚恋,标题下写名字与日期。旧报告不变。

自测:pytest test_report_full_data_fix.py 10 通过;相关四份报告测试退出码 0;前端相关测试 147 通过、0 失败、0 取消;tsc 退出码 0;lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-10-13 21:45:43 +08:00

159 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}