From 7004a1bbbbd5acf2d354491c169fb52108f7bf42 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sat, 18 Jul 2026 04:28:56 +0800 Subject: [PATCH] feat: add birth rectification preview card --- .../src/app/api/birth-rectification/route.ts | 71 +++++++++++++++++++ frontend/src/app/globals.css | 10 +-- frontend/src/app/page.tsx | 50 +++++++++++-- ...est_daily_and_rectification_entrypoints.py | 11 +++ 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/api/birth-rectification/route.ts diff --git a/frontend/src/app/api/birth-rectification/route.ts b/frontend/src/app/api/birth-rectification/route.ts new file mode 100644 index 00000000..9ac14b7b --- /dev/null +++ b/frontend/src/app/api/birth-rectification/route.ts @@ -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) { + 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; + } 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", + }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b1152780..f8179149 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 09236803..bd90e1f1 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(null); const [synastryHistory, setSynastryHistory] = useState([]); const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState(null); + const [birthRectificationPreview, setBirthRectificationPreview] = useState(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() { 探索性日提示,不是确定预测。 - +
+
+ 生时校正 + +
+
+
候选出生时间段
{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}
+
候选点
{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}
+
问题数
{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}
+
+ 不能直接改写默认星盘;需事件证据验证。 +
))} diff --git a/tests/test_daily_and_rectification_entrypoints.py b/tests/test_daily_and_rectification_entrypoints.py index 6c3e27c7..40abc5f4 100644 --- a/tests/test_daily_and_rectification_entrypoints.py +++ b/tests/test_daily_and_rectification_entrypoints.py @@ -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