39181f21ad
ResultAI had h-0 collapsing output; add X-Accel-Buffering no, clearer fetch errors, and NGINX.md for gate proxy setup. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
9.6 KiB
TypeScript
314 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Compass } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import Result from "@/components/result";
|
||
import ResultAI from "@/components/result-ai";
|
||
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
||
import DateTimePicker, {
|
||
nowDateString,
|
||
nowTimeString,
|
||
} from "@/components/shared/datetime-picker";
|
||
import HexagramInput from "@/components/shared/hexagram-input";
|
||
import RegionSelect, {
|
||
useRegionLocation,
|
||
} from "@/components/shared/region-select";
|
||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||
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 [birthTime, setBirthTime] = useState("12:00");
|
||
const [unknownHour, setUnknownHour] = useState(false);
|
||
const [gender, setGender] = useState<"male" | "female">("male");
|
||
const [birthProvince, setBirthProvince] = useState("");
|
||
const [birthCity, setBirthCity] = useState("");
|
||
const [currentProvince, setCurrentProvince] = useState("");
|
||
const [currentCity, setCurrentCity] = useState("");
|
||
const [calcDate, setCalcDate] = useState(nowDateString);
|
||
const [calcTime, setCalcTime] = useState(nowTimeString);
|
||
const [question, setQuestion] = useState("");
|
||
const [withHexagram, setWithHexagram] = useState(false);
|
||
const [castMode, setCastMode] = useState<"online" | "offline">("online");
|
||
const [guaData, setGuaData] = useState<GuaResult | null>(null);
|
||
|
||
const [chart, setChart] = useState<BaziChart | null>(null);
|
||
const [completion, setCompletion] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [showAi, setShowAi] = useState(false);
|
||
|
||
const birthLocation = useRegionLocation(birthProvince, birthCity);
|
||
const currentLocation = useRegionLocation(currentProvince, currentCity);
|
||
const hexagramReady =
|
||
!withHexagram ||
|
||
(question.trim() !== "" && currentLocation !== null && guaData !== null);
|
||
|
||
function validate(): string | null {
|
||
if (!birthLocation) {
|
||
return "请选择出生地域";
|
||
}
|
||
if (!currentLocation) {
|
||
return "请选择当前所在地域";
|
||
}
|
||
if (!question.trim()) {
|
||
return "请输入问事内容";
|
||
}
|
||
if (withHexagram && !guaData) {
|
||
return "请完成六爻起卦,或取消附加六爻";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function handlePreview() {
|
||
const err = validate();
|
||
if (err) {
|
||
setError(err);
|
||
return;
|
||
}
|
||
setError("");
|
||
setChart(
|
||
calculateBazi({
|
||
date: birthDate,
|
||
time: unknownHour ? "12:00" : birthTime,
|
||
gender,
|
||
longitude: birthLocation!.longitude,
|
||
unknownHour,
|
||
}),
|
||
);
|
||
setShowAi(false);
|
||
setCompletion("");
|
||
}
|
||
|
||
async function handleAnalyze() {
|
||
const err = validate();
|
||
if (err) {
|
||
setError(err);
|
||
return;
|
||
}
|
||
if (!chart) {
|
||
setChart(
|
||
calculateBazi({
|
||
date: birthDate,
|
||
time: unknownHour ? "12:00" : birthTime,
|
||
gender,
|
||
longitude: birthLocation!.longitude,
|
||
unknownHour,
|
||
}),
|
||
);
|
||
}
|
||
|
||
setError("");
|
||
setCompletion("");
|
||
setIsLoading(true);
|
||
setShowAi(true);
|
||
|
||
try {
|
||
await streamAiCompletion(
|
||
{
|
||
mode: "combined",
|
||
payload: {
|
||
birth: {
|
||
date: birthDate,
|
||
time: unknownHour ? "12:00" : birthTime,
|
||
gender,
|
||
longitude: birthLocation!.longitude,
|
||
unknownHour,
|
||
},
|
||
birthPlaceName: birthLocation!.name,
|
||
currentPlaceName: currentLocation!.name,
|
||
currentLongitude: currentLocation!.longitude,
|
||
calcDate,
|
||
calcTime,
|
||
question,
|
||
hexagram: withHexagram && guaData
|
||
? {
|
||
guaMark: guaData.result.guaMark,
|
||
guaTitle: guaData.result.guaTitle,
|
||
guaResult: guaData.result.guaResult,
|
||
guaChange: guaData.result.guaChange,
|
||
}
|
||
: undefined,
|
||
},
|
||
},
|
||
setCompletion,
|
||
);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-1 flex-col gap-4 px-4 py-6">
|
||
<div className="mx-auto w-full max-w-lg space-y-6">
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold text-primary">人和 · 生辰八字</h2>
|
||
<DateTimePicker
|
||
label="出生日期 / 时间"
|
||
date={birthDate}
|
||
time={birthTime}
|
||
timeDisabled={unknownHour}
|
||
onDateChange={setBirthDate}
|
||
onTimeChange={setBirthTime}
|
||
/>
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={unknownHour}
|
||
onChange={(e) => setUnknownHour(e.target.checked)}
|
||
/>
|
||
时辰不详
|
||
</label>
|
||
<div className="flex gap-4">
|
||
{(["male", "female"] as const).map((g) => (
|
||
<label key={g} className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="radio"
|
||
name="combined-gender"
|
||
checked={gender === g}
|
||
onChange={() => setGender(g)}
|
||
/>
|
||
{g === "male" ? "男" : "女"}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<RegionSelect
|
||
label="出生地域"
|
||
provinceCode={birthProvince}
|
||
cityCode={birthCity}
|
||
onProvinceChange={setBirthProvince}
|
||
onCityChange={setBirthCity}
|
||
/>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold text-primary">地利 · 当前位置</h2>
|
||
<RegionSelect
|
||
label="所在地域"
|
||
provinceCode={currentProvince}
|
||
cityCode={currentCity}
|
||
onProvinceChange={setCurrentProvince}
|
||
onCityChange={setCurrentCity}
|
||
/>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold text-primary">天时 · 测算时刻</h2>
|
||
<DateTimePicker
|
||
label="日期 / 时间"
|
||
date={calcDate}
|
||
time={calcTime}
|
||
onDateChange={setCalcDate}
|
||
onTimeChange={setCalcTime}
|
||
/>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold text-primary">问事</h2>
|
||
<Textarea
|
||
placeholder="具体所求..."
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
rows={3}
|
||
/>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<label className="flex items-center gap-2 text-sm font-medium">
|
||
<input
|
||
type="checkbox"
|
||
checked={withHexagram}
|
||
onChange={(e) => {
|
||
setWithHexagram(e.target.checked);
|
||
setGuaData(null);
|
||
setShowAi(false);
|
||
}}
|
||
/>
|
||
附加六爻(可选)
|
||
</label>
|
||
{withHexagram && (
|
||
<div className="space-y-3 rounded-md border p-3">
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={castMode === "online" ? "default" : "outline"}
|
||
onClick={() => {
|
||
setCastMode("online");
|
||
setGuaData(null);
|
||
}}
|
||
>
|
||
线上摇卦
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={castMode === "offline" ? "default" : "outline"}
|
||
onClick={() => {
|
||
setCastMode("offline");
|
||
setGuaData(null);
|
||
}}
|
||
>
|
||
线下录入
|
||
</Button>
|
||
</div>
|
||
<HexagramInput
|
||
key={castMode}
|
||
mode={castMode}
|
||
enabled={question.trim() !== "" && currentLocation !== null}
|
||
onResult={setGuaData}
|
||
onClear={() => setGuaData(null)}
|
||
/>
|
||
{guaData && (
|
||
<div className="rounded-md border bg-card p-3">
|
||
<Result {...guaData.result} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{error && !showAi && (
|
||
<p className="text-sm text-destructive">{error}</p>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={handlePreview} className="flex-1">
|
||
预览排盘
|
||
</Button>
|
||
<Button
|
||
onClick={handleAnalyze}
|
||
disabled={withHexagram && !hexagramReady}
|
||
className="flex-1"
|
||
>
|
||
<Compass size={16} className="mr-1" />
|
||
综合测算
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{chart && (
|
||
<div className="mx-auto w-full max-w-lg">
|
||
<BaziChartDisplay chart={chart} />
|
||
</div>
|
||
)}
|
||
|
||
{showAi && (
|
||
<div className="mx-auto w-full max-w-lg pt-2">
|
||
<ResultAI
|
||
completion={completion}
|
||
isLoading={isLoading}
|
||
onCompletion={handleAnalyze}
|
||
error={error}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|