96b659fbe5
Replace missing coin images with CSS 3D coins, auto-select city on province change, expand Shandong cities, and improve AI interpretation prompts after six casts. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
7.4 KiB
TypeScript
256 lines
7.4 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { readStreamableValue } from "ai/rsc";
|
||
import { BrainCircuit, 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 actionRef = useRef<HTMLDivElement>(null);
|
||
|
||
const location = useRegionLocation(provinceCode, cityCode);
|
||
const formReady = question.trim() !== "" && location !== null;
|
||
|
||
useEffect(() => {
|
||
if (guaData && actionRef.current) {
|
||
actionRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
}, [guaData]);
|
||
|
||
function validate(): string | null {
|
||
if (!question.trim()) {
|
||
return "请输入问事";
|
||
}
|
||
if (!location) {
|
||
return "请选择起卦省份";
|
||
}
|
||
if (!guaData) {
|
||
return "请先完成 6 次起卦(线上摇卦或线下录入)";
|
||
}
|
||
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("");
|
||
setError("");
|
||
}
|
||
|
||
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="space-y-3 rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||
<p className="text-sm font-medium text-primary">
|
||
起卦完成 · 请点击下方「AI 解读」
|
||
</p>
|
||
<Result {...guaData.result} />
|
||
</div>
|
||
)}
|
||
|
||
{!guaData && formReady && castMode === "online" && (
|
||
<p className="text-center text-xs text-muted-foreground">
|
||
线上模式将自动摇卦 6 次,完成后出现 AI 解读按钮
|
||
</p>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-sm text-destructive">{error}</p>
|
||
)}
|
||
|
||
<div ref={actionRef} 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"
|
||
>
|
||
<BrainCircuit size={16} className="mr-1" />
|
||
AI 解读
|
||
</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>
|
||
);
|
||
}
|