Files
zhimingge/components/modes/combined-form.tsx
T
dekun 39181f21ad Fix AI result panel visibility and nginx streaming headers.
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>
2026-06-10 22:56:22 +08:00

314 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}