Implement three divination modes, learn pages, and PM2 deploy on port 3130.
Add liuyao/bazi/combined flows with shared calc and AI infrastructure, 64-gua learn routes, and update Ubuntu PM2 deployment docs for port 3130. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { BaziChart, PillarInfo } from "@/lib/calc/bazi";
|
||||
|
||||
function PillarRow({ label, pillar }: { label: string; pillar: PillarInfo }) {
|
||||
return (
|
||||
<tr className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{label}</td>
|
||||
<td className="px-3 py-2 font-mono">{pillar.ganZhi}</td>
|
||||
<td className="px-3 py-2">{pillar.shiShenGan}</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{pillar.shiShenZhi.join("、") || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">{pillar.naYin}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BaziChartDisplay({ chart }: { chart: BaziChart }) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4 text-sm">
|
||||
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
||||
<span>出生:{chart.birthTime}</span>
|
||||
<span>真太阳时:{chart.trueSolarTime}</span>
|
||||
<span className="sm:col-span-2">农历:{chart.lunarDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50 text-left">
|
||||
<tr>
|
||||
<th className="px-3 py-2">柱</th>
|
||||
<th className="px-3 py-2">干支</th>
|
||||
<th className="px-3 py-2">天干十神</th>
|
||||
<th className="px-3 py-2">地支十神</th>
|
||||
<th className="px-3 py-2">纳音</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<PillarRow label="年柱" pillar={chart.pillars.year} />
|
||||
<PillarRow label="月柱" pillar={chart.pillars.month} />
|
||||
<PillarRow label="日柱" pillar={chart.pillars.day} />
|
||||
<PillarRow label="时柱" pillar={chart.pillars.time} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 font-medium">大运</div>
|
||||
<p className="text-muted-foreground">
|
||||
起运 {chart.daYun.startYear} 年 {chart.daYun.startMonth} 月{" "}
|
||||
{chart.daYun.startDay} 天 ·{" "}
|
||||
{chart.daYun.items.map((d) => `${d.startAge}岁 ${d.ganZhi}`).join(" → ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 font-medium">流年</div>
|
||||
<p className="text-muted-foreground">
|
||||
{chart.liuNian.map((l) => `${l.year}(${l.ganZhi})`).join("、")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 font-medium">神煞</div>
|
||||
<p className="text-muted-foreground">
|
||||
吉神:{chart.shenSha.ji.join("、") || "无"}
|
||||
<br />
|
||||
凶煞:{chart.shenSha.xiong.join("、") || "无"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { readStreamableValue } from "ai/rsc";
|
||||
import { BrainCircuit } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ResultAI from "@/components/result-ai";
|
||||
import BaziChartDisplay from "@/components/modes/bazi-chart";
|
||||
import DateTimePicker, { nowDateString } from "@/components/shared/datetime-picker";
|
||||
import RegionSelect, {
|
||||
useRegionLocation,
|
||||
} from "@/components/shared/region-select";
|
||||
import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
|
||||
import { getBaziAnswer } from "@/app/actions/bazi";
|
||||
import { ERROR_PREFIX } from "@/lib/constant";
|
||||
|
||||
export default function BaziForm() {
|
||||
const [date, setDate] = useState(nowDateString());
|
||||
const [time, setTime] = useState("12:00");
|
||||
const [unknownHour, setUnknownHour] = useState(false);
|
||||
const [gender, setGender] = useState<"male" | "female">("male");
|
||||
const [provinceCode, setProvinceCode] = useState("");
|
||||
const [cityCode, setCityCode] = useState("");
|
||||
const [question, setQuestion] = useState("");
|
||||
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 location = useRegionLocation(provinceCode, cityCode);
|
||||
|
||||
function buildInput() {
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
date,
|
||||
time: unknownHour ? "12:00" : time,
|
||||
gender,
|
||||
longitude: location.longitude,
|
||||
unknownHour,
|
||||
};
|
||||
}
|
||||
|
||||
function handleCalculate() {
|
||||
const input = buildInput();
|
||||
if (!input) {
|
||||
setError("请选择出生地域");
|
||||
return;
|
||||
}
|
||||
if (!question.trim()) {
|
||||
setError("请输入问事");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setChart(calculateBazi(input));
|
||||
setShowAi(false);
|
||||
setCompletion("");
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
const input = buildInput();
|
||||
if (!input || !chart) {
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setCompletion("");
|
||||
setIsLoading(true);
|
||||
setShowAi(true);
|
||||
try {
|
||||
const { data, error: apiError } = await getBaziAnswer(
|
||||
input,
|
||||
question,
|
||||
location!.name,
|
||||
);
|
||||
if (apiError) {
|
||||
setError(apiError);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
let ret = "";
|
||||
for await (const delta of readStreamableValue(data)) {
|
||||
if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
|
||||
setError(delta.slice(ERROR_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
ret += delta ?? "";
|
||||
setCompletion(ret);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} 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-4">
|
||||
<DateTimePicker
|
||||
label="出生日期 / 时间"
|
||||
date={date}
|
||||
time={time}
|
||||
timeDisabled={unknownHour}
|
||||
onDateChange={setDate}
|
||||
onTimeChange={setTime}
|
||||
/>
|
||||
<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="space-y-1">
|
||||
<label className="text-sm font-medium">性别</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="gender"
|
||||
checked={gender === g}
|
||||
onChange={() => setGender(g)}
|
||||
/>
|
||||
{g === "male" ? "男" : "女"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegionSelect
|
||||
label="出生地域"
|
||||
provinceCode={provinceCode}
|
||||
cityCode={cityCode}
|
||||
onProvinceChange={setProvinceCode}
|
||||
onCityChange={setCityCode}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">问事</label>
|
||||
<Textarea
|
||||
placeholder="事业、婚姻、健康等..."
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && !showAi && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<Button onClick={handleCalculate} className="w-full">
|
||||
排盘
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{chart && (
|
||||
<div className="mx-auto w-full max-w-lg space-y-4">
|
||||
<BaziChartDisplay chart={chart} />
|
||||
{!showAi && (
|
||||
<Button onClick={handleAnalyze} className="w-full">
|
||||
<BrainCircuit size={16} className="mr-1" />
|
||||
测算
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAi && (
|
||||
<div className="mx-auto h-96 w-full max-w-lg flex-1">
|
||||
<ResultAI
|
||||
completion={completion}
|
||||
isLoading={isLoading}
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { readStreamableValue } from "ai/rsc";
|
||||
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 { getCombinedAnswer } from "@/app/actions/combined";
|
||||
import { ERROR_PREFIX } from "@/lib/constant";
|
||||
|
||||
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 {
|
||||
const { data, error: apiError } = await getCombinedAnswer({
|
||||
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,
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
setError(apiError);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
let ret = "";
|
||||
for await (const delta of readStreamableValue(data)) {
|
||||
if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
|
||||
setError(delta.slice(ERROR_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
ret += delta ?? "";
|
||||
setCompletion(ret);
|
||||
}
|
||||
}
|
||||
} 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 h-96 w-full max-w-lg flex-1">
|
||||
<ResultAI
|
||||
completion={completion}
|
||||
isLoading={isLoading}
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { readStreamableValue } from "ai/rsc";
|
||||
import { Compass, ListRestart } 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 DateTimePicker, {
|
||||
nowDateString,
|
||||
nowTimeString,
|
||||
} from "@/components/shared/datetime-picker";
|
||||
import HexagramInput from "@/components/shared/hexagram-input";
|
||||
import RegionSelect, {
|
||||
useRegionLocation,
|
||||
} from "@/components/shared/region-select";
|
||||
import { getLiuyaoAnswer } from "@/app/actions/liuyao";
|
||||
import type { GuaResult } from "@/lib/calc/hexagram";
|
||||
import { ERROR_PREFIX } from "@/lib/constant";
|
||||
import todayJson from "@/lib/data/today.json";
|
||||
|
||||
const todayData: string[] = todayJson;
|
||||
|
||||
export default function LiuyaoForm() {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [provinceCode, setProvinceCode] = useState("");
|
||||
const [cityCode, setCityCode] = useState("");
|
||||
const [calcDate, setCalcDate] = useState(nowDateString);
|
||||
const [calcTime, setCalcTime] = useState(nowTimeString);
|
||||
const [castMode, setCastMode] = useState<"online" | "offline">("online");
|
||||
const [guaData, setGuaData] = useState<GuaResult | null>(null);
|
||||
const [completion, setCompletion] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showAi, setShowAi] = useState(false);
|
||||
|
||||
const location = useRegionLocation(provinceCode, cityCode);
|
||||
const formReady = question.trim() !== "" && location !== null;
|
||||
|
||||
function validate(): string | null {
|
||||
if (!question.trim()) {
|
||||
return "请输入问事";
|
||||
}
|
||||
if (!location) {
|
||||
return "请选择起卦地域";
|
||||
}
|
||||
if (!guaData) {
|
||||
return "请先完成起卦(线上摇卦或线下录入)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
const err = validate();
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setCompletion("");
|
||||
setIsLoading(true);
|
||||
setShowAi(true);
|
||||
|
||||
try {
|
||||
const { data, error: apiError } = await getLiuyaoAnswer({
|
||||
question,
|
||||
calcDate,
|
||||
calcTime,
|
||||
locationName: location!.name,
|
||||
longitude: location!.longitude,
|
||||
guaMark: guaData!.result.guaMark,
|
||||
guaTitle: guaData!.result.guaTitle,
|
||||
guaResult: guaData!.result.guaResult,
|
||||
guaChange: guaData!.result.guaChange,
|
||||
});
|
||||
|
||||
if (apiError) {
|
||||
setError(apiError);
|
||||
return;
|
||||
}
|
||||
if (data) {
|
||||
let ret = "";
|
||||
for await (const delta of readStreamableValue(data)) {
|
||||
if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
|
||||
setError(delta.slice(ERROR_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
ret += delta ?? "";
|
||||
setCompletion(ret);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setQuestion("");
|
||||
setProvinceCode("");
|
||||
setCityCode("");
|
||||
setCalcDate(nowDateString());
|
||||
setCalcTime(nowTimeString());
|
||||
setGuaData(null);
|
||||
setCompletion("");
|
||||
setError("");
|
||||
setShowAi(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
function handleGuaResult(data: GuaResult) {
|
||||
setGuaData(data);
|
||||
setShowAi(false);
|
||||
setCompletion("");
|
||||
}
|
||||
|
||||
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-5">
|
||||
<section className="space-y-2">
|
||||
<label className="text-sm font-medium">问事</label>
|
||||
<Textarea
|
||||
placeholder="您想算点什么?"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{todayData.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => setQuestion(item)}
|
||||
className="rounded-md border bg-secondary px-2 py-1 text-xs text-muted-foreground transition hover:bg-accent"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RegionSelect
|
||||
label="起卦地域"
|
||||
provinceCode={provinceCode}
|
||||
cityCode={cityCode}
|
||||
onProvinceChange={setProvinceCode}
|
||||
onCityChange={setCityCode}
|
||||
/>
|
||||
|
||||
<DateTimePicker
|
||||
label="起卦时辰"
|
||||
date={calcDate}
|
||||
time={calcTime}
|
||||
onDateChange={setCalcDate}
|
||||
onTimeChange={setCalcTime}
|
||||
/>
|
||||
|
||||
<section className="space-y-2">
|
||||
<label className="text-sm font-medium">起卦方式</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={castMode === "online" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setCastMode("online");
|
||||
setGuaData(null);
|
||||
setShowAi(false);
|
||||
}}
|
||||
>
|
||||
线上摇卦
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={castMode === "offline" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setCastMode("offline");
|
||||
setGuaData(null);
|
||||
setShowAi(false);
|
||||
}}
|
||||
>
|
||||
线下录入
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HexagramInput
|
||||
key={castMode}
|
||||
mode={castMode}
|
||||
enabled={formReady}
|
||||
onResult={handleGuaResult}
|
||||
onClear={() => setGuaData(null)}
|
||||
/>
|
||||
|
||||
{guaData && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<Result {...guaData.result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !showAi && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReset} className="flex-1">
|
||||
<ListRestart size={16} className="mr-1" />
|
||||
重来
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!guaData || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
<Compass size={16} className="mr-1" />
|
||||
测算
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAi && (
|
||||
<div className="mx-auto h-96 w-full max-w-lg flex-1">
|
||||
<ResultAI
|
||||
completion={completion}
|
||||
isLoading={isLoading}
|
||||
onCompletion={handleAnalyze}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user