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:
@@ -8,7 +8,9 @@ import { ZenCard } from "@/components/ui/zen-card";
|
|||||||
import { ModeWorkspace } from "@/components/layout/mode-workspace";
|
import { ModeWorkspace } from "@/components/layout/mode-workspace";
|
||||||
import ResultAI from "@/components/result-ai";
|
import ResultAI from "@/components/result-ai";
|
||||||
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
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, {
|
import RegionSelect, {
|
||||||
useRegionLocation,
|
useRegionLocation,
|
||||||
} from "@/components/shared/region-select";
|
} from "@/components/shared/region-select";
|
||||||
@@ -16,7 +18,7 @@ import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
|||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
|
|
||||||
export default function BaziForm() {
|
export default function BaziForm() {
|
||||||
const [date, setDate] = useState(nowDateString());
|
const [date, setDate] = useState(todaySolarYmd());
|
||||||
const [time, setTime] = useState("12:00");
|
const [time, setTime] = useState("12:00");
|
||||||
const [unknownHour, setUnknownHour] = useState(false);
|
const [unknownHour, setUnknownHour] = useState(false);
|
||||||
const [gender, setGender] = useState<"male" | "female">("male");
|
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">
|
<ZenCard title="出生 · 命局" subtitle="四柱排盘所需信息" className="flex h-full min-h-0 flex-col">
|
||||||
<DateTimePicker
|
<LunarBirthPicker
|
||||||
label="出生日期 / 时间"
|
label="出生日期 / 时间"
|
||||||
date={date}
|
solarDate={date}
|
||||||
time={time}
|
time={time}
|
||||||
timeDisabled={unknownHour}
|
timeDisabled={unknownHour}
|
||||||
onDateChange={setDate}
|
onSolarDateChange={setDate}
|
||||||
onTimeChange={setTime}
|
onTimeChange={setTime}
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { ModeWorkspace } from "@/components/layout/mode-workspace";
|
|||||||
import Result from "@/components/result";
|
import Result from "@/components/result";
|
||||||
import ResultAI from "@/components/result-ai";
|
import ResultAI from "@/components/result-ai";
|
||||||
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
||||||
|
import LunarBirthPicker, {
|
||||||
|
todaySolarYmd,
|
||||||
|
} from "@/components/shared/lunar-birth-picker";
|
||||||
import DateTimePicker, {
|
import DateTimePicker, {
|
||||||
nowDateString,
|
nowDateString,
|
||||||
nowTimeString,
|
nowTimeString,
|
||||||
@@ -22,7 +25,7 @@ import type { GuaResult } from "@/lib/calc/hexagram";
|
|||||||
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
import { streamAiCompletion } from "@/lib/ai/client-stream";
|
||||||
|
|
||||||
export default function CombinedForm() {
|
export default function CombinedForm() {
|
||||||
const [birthDate, setBirthDate] = useState("1990-01-01");
|
const [birthDate, setBirthDate] = useState(todaySolarYmd());
|
||||||
const [birthTime, setBirthTime] = useState("12:00");
|
const [birthTime, setBirthTime] = useState("12:00");
|
||||||
const [unknownHour, setUnknownHour] = useState(false);
|
const [unknownHour, setUnknownHour] = useState(false);
|
||||||
const [gender, setGender] = useState<"male" | "female">("male");
|
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">
|
<ZenCard title="人和 · 生辰" subtitle="出生时空与地域" className="flex h-full min-h-0 flex-col">
|
||||||
<DateTimePicker
|
<LunarBirthPicker
|
||||||
label="出生日期 / 时间"
|
label="出生日期 / 时间(农历)"
|
||||||
date={birthDate}
|
solarDate={birthDate}
|
||||||
time={birthTime}
|
time={birthTime}
|
||||||
timeDisabled={unknownHour}
|
timeDisabled={unknownHour}
|
||||||
onDateChange={setBirthDate}
|
onSolarDateChange={setBirthDate}
|
||||||
onTimeChange={setBirthTime}
|
onTimeChange={setBirthTime}
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+17
@@ -1,5 +1,10 @@
|
|||||||
declare module "lunar-javascript" {
|
declare module "lunar-javascript" {
|
||||||
export class Solar {
|
export class Solar {
|
||||||
|
static fromYmd(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
): Solar;
|
||||||
static fromYmdHms(
|
static fromYmdHms(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
@@ -8,12 +13,24 @@ declare module "lunar-javascript" {
|
|||||||
minute: number,
|
minute: number,
|
||||||
second: number,
|
second: number,
|
||||||
): Solar;
|
): Solar;
|
||||||
|
static fromDate(date: Date): Solar;
|
||||||
getLunar(): Lunar;
|
getLunar(): Lunar;
|
||||||
|
getWeek(): number;
|
||||||
toYmd(): string;
|
toYmd(): string;
|
||||||
toYmdHms(): string;
|
toYmdHms(): string;
|
||||||
|
toFullString(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Lunar {
|
export class Lunar {
|
||||||
|
static fromYmd(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
): Lunar;
|
||||||
|
getSolar(): Solar;
|
||||||
|
getYear(): number;
|
||||||
|
getMonth(): number;
|
||||||
|
getDay(): number;
|
||||||
getEightChar(): EightChar;
|
getEightChar(): EightChar;
|
||||||
getDayInGanZhi(): string;
|
getDayInGanZhi(): string;
|
||||||
getTimeInGanZhi(): string;
|
getTimeInGanZhi(): string;
|
||||||
|
|||||||
Reference in New Issue
Block a user