Files
zhimingge/components/modes/liuyao-form.tsx
T
dekun 96b659fbe5 Fix liuyao UX: CSS coins, region auto-select, and clearer AI flow.
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>
2026-06-10 21:24:51 +08:00

256 lines
7.4 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 { 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>
);
}