Use lunar calendar input for birth date with solar conversion display.

Add LunarBirthPicker for bazi and combined forms, converting lunar input to solar for chart calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-13 09:06:37 +08:00
parent 1cde9ffc9c
commit abf78cbbb5
5 changed files with 250 additions and 10 deletions
+7 -5
View File
@@ -8,7 +8,9 @@ import { ZenCard } from "@/components/ui/zen-card";
import { ModeWorkspace } from "@/components/layout/mode-workspace";
import ResultAI from "@/components/result-ai";
import BaziChartDisplay from "@/components/modes/bazi-chart";
import DateTimePicker, { nowDateString } from "@/components/shared/datetime-picker";
import LunarBirthPicker, {
todaySolarYmd,
} from "@/components/shared/lunar-birth-picker";
import RegionSelect, {
useRegionLocation,
} from "@/components/shared/region-select";
@@ -16,7 +18,7 @@ import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
import { streamAiCompletion } from "@/lib/ai/client-stream";
export default function BaziForm() {
const [date, setDate] = useState(nowDateString());
const [date, setDate] = useState(todaySolarYmd());
const [time, setTime] = useState("12:00");
const [unknownHour, setUnknownHour] = useState(false);
const [gender, setGender] = useState<"male" | "female">("male");
@@ -110,12 +112,12 @@ export default function BaziForm() {
}
>
<ZenCard title="出生 · 命局" subtitle="四柱排盘所需信息" className="flex h-full min-h-0 flex-col">
<DateTimePicker
<LunarBirthPicker
label="出生日期 / 时间"
date={date}
solarDate={date}
time={time}
timeDisabled={unknownHour}
onDateChange={setDate}
onSolarDateChange={setDate}
onTimeChange={setTime}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
+8 -5
View File
@@ -9,6 +9,9 @@ import { ModeWorkspace } from "@/components/layout/mode-workspace";
import Result from "@/components/result";
import ResultAI from "@/components/result-ai";
import BaziChartDisplay from "@/components/modes/bazi-chart";
import LunarBirthPicker, {
todaySolarYmd,
} from "@/components/shared/lunar-birth-picker";
import DateTimePicker, {
nowDateString,
nowTimeString,
@@ -22,7 +25,7 @@ import type { GuaResult } from "@/lib/calc/hexagram";
import { streamAiCompletion } from "@/lib/ai/client-stream";
export default function CombinedForm() {
const [birthDate, setBirthDate] = useState("1990-01-01");
const [birthDate, setBirthDate] = useState(todaySolarYmd());
const [birthTime, setBirthTime] = useState("12:00");
const [unknownHour, setUnknownHour] = useState(false);
const [gender, setGender] = useState<"male" | "female">("male");
@@ -163,12 +166,12 @@ export default function CombinedForm() {
}
>
<ZenCard title="人和 · 生辰" subtitle="出生时空与地域" className="flex h-full min-h-0 flex-col">
<DateTimePicker
label="出生日期 / 时间"
date={birthDate}
<LunarBirthPicker
label="出生日期 / 时间(农历)"
solarDate={birthDate}
time={birthTime}
timeDisabled={unknownHour}
onDateChange={setBirthDate}
onSolarDateChange={setBirthDate}
onTimeChange={setBirthTime}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
formatLunarLabel,
formatSolarLabel,
lunarToSolarYmd,
solarYmdToLunarParts,
todayLunarParts,
todaySolarYmd,
type LunarDateParts,
} from "@/lib/calc/lunar-date";
interface LunarBirthPickerProps {
solarDate: string;
time: string;
onSolarDateChange: (solarYmd: string) => void;
onTimeChange: (time: string) => void;
label?: string;
timeDisabled?: boolean;
}
const inputClass =
"rounded-md border bg-background px-3 py-2 text-sm disabled:opacity-50";
export default function LunarBirthPicker({
solarDate,
time,
onSolarDateChange,
onTimeChange,
label = "出生日期 / 时间",
timeDisabled = false,
}: LunarBirthPickerProps) {
const [lunar, setLunar] = useState<LunarDateParts>(() => {
return solarYmdToLunarParts(solarDate) ?? todayLunarParts();
});
useEffect(() => {
const parsed = solarYmdToLunarParts(solarDate);
if (parsed) {
setLunar(parsed);
}
}, [solarDate]);
const solarYmd = useMemo(() => lunarToSolarYmd(lunar), [lunar]);
const lunarLabel = useMemo(() => formatLunarLabel(lunar), [lunar]);
const solarLabel = useMemo(
() => (solarYmd ? formatSolarLabel(solarYmd) : null),
[solarYmd],
);
useEffect(() => {
if (solarYmd && solarYmd !== solarDate) {
onSolarDateChange(solarYmd);
}
}, [solarYmd, solarDate, onSolarDateChange]);
function updateLunar(patch: Partial<LunarDateParts>) {
setLunar((prev) => ({ ...prev, ...patch }));
}
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<p className="text-xs text-muted-foreground"></p>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<input
type="number"
className={inputClass + " w-full"}
min={1900}
max={2100}
value={lunar.year}
onChange={(e) => updateLunar({ year: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<input
type="number"
className={inputClass + " w-full"}
min={1}
max={12}
value={lunar.month}
onChange={(e) => updateLunar({ month: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<input
type="number"
className={inputClass + " w-full"}
min={1}
max={30}
value={lunar.day}
onChange={(e) => updateLunar({ day: Number(e.target.value) })}
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={lunar.isLeapMonth}
onChange={(e) => updateLunar({ isLeapMonth: e.target.checked })}
/>
</label>
{lunarLabel && (
<p className="text-xs text-muted-foreground">{lunarLabel}</p>
)}
{solarLabel ? (
<p className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium text-foreground">{solarLabel}</span>
<span className="ml-2 text-xs text-muted-foreground">({solarYmd})</span>
</p>
) : (
<p className="text-sm text-destructive"></p>
)}
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<input
type="time"
className={inputClass + " w-full sm:w-auto"}
value={time}
disabled={timeDisabled}
onChange={(e) => onTimeChange(e.target.value)}
/>
</div>
</div>
);
}
export { todaySolarYmd };
+79
View File
@@ -0,0 +1,79 @@
import { Lunar, Solar } from "lunar-javascript";
export interface LunarDateParts {
year: number;
month: number;
day: number;
isLeapMonth: boolean;
}
/** 农历 → 阳历 YYYY-MM-DD,无效则 null */
export function lunarToSolarYmd(parts: LunarDateParts): string | null {
try {
const month = parts.isLeapMonth ? -parts.month : parts.month;
const lunar = Lunar.fromYmd(parts.year, month, parts.day);
return lunar.getSolar().toYmd();
} catch {
return null;
}
}
/** 阳历 YYYY-MM-DD → 农历分量 */
export function solarYmdToLunarParts(ymd: string): LunarDateParts | null {
try {
const [y, m, d] = ymd.split("-").map(Number);
if (!y || !m || !d) {
return null;
}
const lunar = Solar.fromYmd(y, m, d).getLunar();
const month = lunar.getMonth();
return {
year: lunar.getYear(),
month: Math.abs(month),
day: lunar.getDay(),
isLeapMonth: month < 0,
};
} catch {
return null;
}
}
/** 格式化农历显示 */
export function formatLunarLabel(parts: LunarDateParts): string | null {
try {
const month = parts.isLeapMonth ? -parts.month : parts.month;
return Lunar.fromYmd(parts.year, month, parts.day).toString();
} catch {
return null;
}
}
/** 格式化阳历显示 */
export function formatSolarLabel(ymd: string): string | null {
try {
const [y, m, d] = ymd.split("-").map(Number);
if (!y || !m || !d) {
return null;
}
const solar = Solar.fromYmd(y, m, d);
const week = ["日", "一", "二", "三", "四", "五", "六"][solar.getWeek()];
return `${y}${m}${d}日 星期${week}`;
} catch {
return null;
}
}
export function todayLunarParts(): LunarDateParts {
const lunar = Solar.fromDate(new Date()).getLunar();
const month = lunar.getMonth();
return {
year: lunar.getYear(),
month: Math.abs(month),
day: lunar.getDay(),
isLeapMonth: month < 0,
};
}
export function todaySolarYmd(): string {
return Solar.fromDate(new Date()).toYmd();
}
+17
View File
@@ -1,5 +1,10 @@
declare module "lunar-javascript" {
export class Solar {
static fromYmd(
year: number,
month: number,
day: number,
): Solar;
static fromYmdHms(
year: number,
month: number,
@@ -8,12 +13,24 @@ declare module "lunar-javascript" {
minute: number,
second: number,
): Solar;
static fromDate(date: Date): Solar;
getLunar(): Lunar;
getWeek(): number;
toYmd(): string;
toYmdHms(): string;
toFullString(): string;
}
export class Lunar {
static fromYmd(
year: number,
month: number,
day: number,
): Lunar;
getSolar(): Solar;
getYear(): number;
getMonth(): number;
getDay(): number;
getEightChar(): EightChar;
getDayInGanZhi(): string;
getTimeInGanZhi(): string;