feat: add birth rectification preview card

This commit is contained in:
732642856
2026-07-18 04:28:56 +08:00
parent 3ef595ebc9
commit 7004a1bbbb
4 changed files with 133 additions and 9 deletions
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { chinaLocations } from "@/data/china-locations";
type Profile = {
date?: string;
time?: string;
provinceCode?: string;
cityCode?: string;
districtCode?: string;
};
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
function payloadFromProfile(profile: Profile) {
if (!profile.date || !profile.time) return null;
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
const city = province?.cities.find((item) => item.code === profile.cityCode);
const district = city?.districts.find((item) => item.code === profile.districtCode);
const location = district ?? city;
if (!location) return null;
return {
birth_time: `${profile.date} ${profile.time}`,
uncertainty_minutes: 30,
step_minutes: 2,
lat: location.center[1],
lon: location.center[0],
tz: chinaLocations.country.timezone,
};
}
async function fetchQuestionnaire(payload: Record<string, unknown>) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(`${jyotishApiBase}/api/active_rectification_questions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
return await response.json() as Record<string, unknown>;
} finally {
clearTimeout(timeout);
}
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null) as { profile?: Profile } | null;
const payload = payloadFromProfile(body?.profile ?? {});
if (!payload) {
return NextResponse.json({
status: "blocked",
boundary: "not_auto_rectified",
reason: "profile_incomplete",
});
}
const questionnaire = await fetchQuestionnaire(payload).catch(() => null);
const candidateScan = questionnaire?.candidate_scan && typeof questionnaire.candidate_scan === "object"
? questionnaire.candidate_scan
: null;
const questions = Array.isArray(questionnaire?.questions) ? questionnaire.questions : [];
return NextResponse.json({
status: questionnaire ? "ok" : "blocked",
candidate_scan: candidateScan,
question_count: questions.length,
boundary: "not_auto_rectified",
source: questionnaire ? "active_rectification_questions" : "fallback_unavailable",
});
}
+5 -5
View File
@@ -315,14 +315,14 @@ button:disabled { cursor: default; opacity: .45; }
.product-entrypoints button { min-height: 132px; align-items: end; border: 1px solid var(--color-border); background: var(--color-canvas); }
.product-entrypoints span { display: grid; gap: var(--space-2); }
.product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); }
.daily-starlanguage-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; }
.daily-starlanguage-heading button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; color: var(--color-ink); font-size: var(--type-caption); }
.daily-starlanguage-card dl { display: grid; gap: var(--space-2); margin: 0; }
.daily-starlanguage-card div { display: grid; gap: 4px; }
.daily-starlanguage-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.daily-starlanguage-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; }
.daily-starlanguage-card div, .birth-rectification-card div { display: grid; gap: 4px; }
.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
+46 -4
View File
@@ -81,6 +81,13 @@ type DailyStarlanguageApiResponse = {
claim_status?: "exploratory_unvalidated";
boundary?: "not_deterministic_prediction";
};
type BirthRectificationPreview = {
status?: "ok" | "blocked";
candidate_scan?: { start?: string; end?: string; candidate_count?: number };
question_count?: number;
boundary?: "not_auto_rectified";
source?: "active_rectification_questions" | "fallback_unavailable";
};
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type PendingConsultation = {
readonly requestId: string;
@@ -360,6 +367,16 @@ async function fetchDailyStarlanguage(profile: Profile) {
return payload.card;
}
async function fetchBirthRectificationPreview(profile: Profile) {
const response = await fetch("/api/birth-rectification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile }),
});
if (!response.ok) throw new Error("birth_rectification_preview_unavailable");
return await response.json().catch(() => null) as BirthRectificationPreview | null;
}
function buildBirthTimeRectificationQuestion(profile: Profile) {
return [
`请为${profile.name || "我"}做生时校正辅助。`,
@@ -637,6 +654,7 @@ export default function Home() {
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
const [birthRectificationPreview, setBirthRectificationPreview] = useState<BirthRectificationPreview | null>(null);
const [profileOpen, setProfileOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [profileNotice, setProfileNotice] = useState("");
@@ -1003,6 +1021,22 @@ export default function Home() {
};
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
useEffect(() => {
if (!hydrated || !profileComplete) return;
let cancelled = false;
setBirthRectificationPreview(null);
void fetchBirthRectificationPreview(profile)
.then((preview) => {
if (!cancelled) setBirthRectificationPreview(preview);
})
.catch(() => {
if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" });
});
return () => {
cancelled = true;
};
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" });
@@ -2017,10 +2051,18 @@ export default function Home() {
</dl>
<small></small>
</article>
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>
<span><b></b><small></small></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
<article className="birth-rectification-card" aria-label="生时校正">
<div className="daily-starlanguage-heading">
<span></span>
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}> <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
</div>
<dl>
<div><dt></dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
<div><dt></dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count}` : "待后端生成"}</dd></div>
<div><dt></dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
</dl>
<small></small>
</article>
</div>
</div>
))}
@@ -3,6 +3,7 @@ from pathlib import Path
PAGE = Path("frontend/src/app/page.tsx")
DAILY_ROUTE = Path("frontend/src/app/api/daily-starlanguage/route.ts")
RECTIFICATION_ROUTE = Path("frontend/src/app/api/birth-rectification/route.ts")
def test_daily_starlanguage_entrypoint_is_productized() -> None:
@@ -33,6 +34,16 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
def test_birth_time_rectification_entrypoint_is_productized() -> None:
source = PAGE.read_text(encoding="utf-8")
assert "生时校正" in source
assert "fetchBirthRectificationPreview" in source
assert "birth-rectification-card" in source
assert "draftBirthTimeRectificationQuestion" in source
assert "候选出生时间段" in source
assert "不能直接改写默认星盘" in source
def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None:
source = RECTIFICATION_ROUTE.read_text(encoding="utf-8")
assert "/api/active_rectification_questions" in source
assert "candidate_scan" in source
assert "question_count" in source
assert "not_auto_rectified" in source