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>
This commit is contained in:
dekun
2026-06-10 21:24:51 +08:00
parent a1667eac51
commit 96b659fbe5
6 changed files with 123 additions and 64 deletions
+39 -35
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image";
const rotationDuration = 3800; const rotationDuration = 3800;
const bezier = "cubic-bezier(0.645,0.045,0.355,1)"; const bezier = "cubic-bezier(0.645,0.045,0.355,1)";
@@ -8,21 +7,21 @@ const bezier = "cubic-bezier(0.645,0.045,0.355,1)";
function Coin(props: { function Coin(props: {
frontList: boolean[]; frontList: boolean[];
rotation: boolean; rotation: boolean;
onTransitionEnd: any; onTransitionEnd: () => void;
}) { }) {
const [lastFront, setLastFront] = useState(props.frontList); const [lastFront, setLastFront] = useState(props.frontList);
useEffect(function () { useEffect(() => {
if (!props.rotation) { if (!props.rotation) {
return; return;
} }
let id = setTimeout(function () { const id = setTimeout(() => {
setLastFront(props.frontList); setLastFront(props.frontList);
props.onTransitionEnd(); props.onTransitionEnd();
}, rotationDuration); }, rotationDuration);
return () => clearTimeout(id); return () => clearTimeout(id);
}); }, [props.rotation, props.frontList, props.onTransitionEnd]);
return ( return (
<div className="flex w-full max-w-md justify-around rounded-md border bg-secondary p-4 shadow dark:border-0 dark:shadow-none sm:p-6"> <div className="flex w-full max-w-md justify-around rounded-md border bg-secondary p-4 shadow dark:border-0 dark:shadow-none sm:p-6">
@@ -38,50 +37,55 @@ function Coin(props: {
); );
} }
function CoinFace({ side }: { side: "front" | "back" }) {
const isFront = side === "front";
return (
<div
className={clsx(
"absolute inset-0 flex items-center justify-center rounded-full border-[3px] shadow-inner",
isFront
? "border-amber-500 bg-gradient-to-br from-amber-200 to-amber-400 text-amber-950"
: "border-stone-500 bg-gradient-to-br from-stone-300 to-stone-500 text-stone-800",
)}
style={{
backfaceVisibility: "hidden",
transform: isFront ? undefined : "rotateY(180deg)",
}}
>
<span className="select-none text-lg font-bold sm:text-xl">
{isFront ? "正" : "反"}
</span>
</div>
);
}
function CoinItem(props: { function CoinItem(props: {
front: boolean; front: boolean;
lastFront: boolean; lastFront: boolean;
rotation: boolean; rotation: boolean;
onTransitionEnd?: any;
}) { }) {
let animate = ""; let animate = "";
if (props.rotation) { if (props.rotation) {
// animate-[coin-front-front_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
// animate-[coin-front-back_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
// animate-[coin-back-front_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
// animate-[coin-back-back_3.8s_cubic-bezier(0.645,0.045,0.355,1)]
animate = `animate-[coin-${getFront(props.lastFront)}-${getFront( animate = `animate-[coin-${getFront(props.lastFront)}-${getFront(
props.front, props.front,
)}_${rotationDuration / 1000}s_${bezier}]`; )}_${rotationDuration / 1000}s_${bezier}]`;
} }
return ( return (
<div <div
style={{ className="h-16 w-16 sm:h-20 sm:w-20"
transform: `rotateY(${props.front ? 0 : 180}deg)`, style={{ perspective: "800px" }}
transformStyle: "preserve-3d",
transformOrigin: "50% 50% -0.5px",
}}
className={clsx("h-16 w-16 sm:h-20 sm:w-20", animate)}
> >
<Image <div
width={0} style={{
height={0} transform: `rotateY(${props.front ? 0 : 180}deg)`,
sizes="100vw" transformStyle: "preserve-3d",
draggable={false} }}
className="absolute w-full" className={clsx("relative h-full w-full", animate)}
src="/img/head.webp" >
alt="coin" <CoinFace side="front" />
/> <CoinFace side="back" />
<Image </div>
width={0}
height={0}
sizes="100vw"
draggable={false}
className="absolute h-full w-full"
style={{ transform: "translateZ(-1px)" }}
src="/img/tail.webp"
alt="coin"
/>
</div> </div>
); );
} }
+27 -9
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { readStreamableValue } from "ai/rsc"; import { readStreamableValue } from "ai/rsc";
import { Compass, ListRestart } from "lucide-react"; import { BrainCircuit, ListRestart } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import Result from "@/components/result"; import Result from "@/components/result";
@@ -35,18 +35,26 @@ export default function LiuyaoForm() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showAi, setShowAi] = useState(false); const [showAi, setShowAi] = useState(false);
const actionRef = useRef<HTMLDivElement>(null);
const location = useRegionLocation(provinceCode, cityCode); const location = useRegionLocation(provinceCode, cityCode);
const formReady = question.trim() !== "" && location !== null; const formReady = question.trim() !== "" && location !== null;
useEffect(() => {
if (guaData && actionRef.current) {
actionRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [guaData]);
function validate(): string | null { function validate(): string | null {
if (!question.trim()) { if (!question.trim()) {
return "请输入问事"; return "请输入问事";
} }
if (!location) { if (!location) {
return "请选择起卦地域"; return "请选择起卦省份";
} }
if (!guaData) { if (!guaData) {
return "请先完成起卦(线上摇卦或线下录入)"; return "请先完成 6 次起卦(线上摇卦或线下录入)";
} }
return null; return null;
} }
@@ -115,6 +123,7 @@ export default function LiuyaoForm() {
setGuaData(data); setGuaData(data);
setShowAi(false); setShowAi(false);
setCompletion(""); setCompletion("");
setError("");
} }
return ( return (
@@ -197,16 +206,25 @@ export default function LiuyaoForm() {
/> />
{guaData && ( {guaData && (
<div className="rounded-lg border bg-card p-4"> <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} /> <Result {...guaData.result} />
</div> </div>
)} )}
{error && !showAi && ( {!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> <p className="text-sm text-destructive">{error}</p>
)} )}
<div className="flex gap-2"> <div ref={actionRef} className="flex gap-2">
<Button variant="outline" onClick={handleReset} className="flex-1"> <Button variant="outline" onClick={handleReset} className="flex-1">
<ListRestart size={16} className="mr-1" /> <ListRestart size={16} className="mr-1" />
@@ -216,8 +234,8 @@ export default function LiuyaoForm() {
disabled={!guaData || isLoading} disabled={!guaData || isLoading}
className="flex-1" className="flex-1"
> >
<Compass size={16} className="mr-1" /> <BrainCircuit size={16} className="mr-1" />
AI
</Button> </Button>
</div> </div>
</div> </div>
+8 -3
View File
@@ -191,9 +191,14 @@ export default function HexagramInput({
<div className="flex flex-col items-center gap-3 sm:flex-row"> <div className="flex flex-col items-center gap-3 sm:flex-row">
<Hexagram list={hexagramList} /> <Hexagram list={hexagramList} />
{complete && ( {complete && (
<Button size="sm" variant="outline" onClick={handleReset}> <>
<p className="text-sm text-muted-foreground sm:max-w-[10rem]">
</Button> AI
</p>
<Button size="sm" variant="outline" onClick={handleReset}>
</Button>
</>
)} )}
</div> </div>
)} )}
+32 -8
View File
@@ -1,6 +1,10 @@
"use client"; "use client";
import { getProvinces, getCities, getRegionLocation } from "@/lib/data/regions"; import {
getProvinces,
getCities,
getRegionLocation,
} from "@/lib/data/regions";
interface RegionSelectProps { interface RegionSelectProps {
provinceCode: string; provinceCode: string;
@@ -8,6 +12,7 @@ interface RegionSelectProps {
onProvinceChange: (code: string) => void; onProvinceChange: (code: string) => void;
onCityChange: (code: string) => void; onCityChange: (code: string) => void;
label?: string; label?: string;
cityOptional?: boolean;
} }
export default function RegionSelect({ export default function RegionSelect({
@@ -16,9 +21,23 @@ export default function RegionSelect({
onProvinceChange, onProvinceChange,
onCityChange, onCityChange,
label = "出生地域", label = "出生地域",
cityOptional = true,
}: RegionSelectProps) { }: RegionSelectProps) {
const provinces = getProvinces(); const provinces = getProvinces();
const cities = provinceCode ? getCities(provinceCode) : []; const cities = provinceCode ? getCities(provinceCode) : [];
const location = provinceCode
? getRegionLocation(provinceCode, cityCode)
: null;
function handleProvinceChange(code: string) {
onProvinceChange(code);
if (!code) {
onCityChange("");
return;
}
const list = getCities(code);
onCityChange(list[0]?.code ?? "");
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -27,10 +46,7 @@ export default function RegionSelect({
<select <select
className="rounded-md border bg-background px-3 py-2 text-sm" className="rounded-md border bg-background px-3 py-2 text-sm"
value={provinceCode} value={provinceCode}
onChange={(e) => { onChange={(e) => handleProvinceChange(e.target.value)}
onProvinceChange(e.target.value);
onCityChange("");
}}
> >
<option value=""></option> <option value=""></option>
{provinces.map((p) => ( {provinces.map((p) => (
@@ -40,12 +56,14 @@ export default function RegionSelect({
))} ))}
</select> </select>
<select <select
className="rounded-md border bg-background px-3 py-2 text-sm" className="rounded-md border bg-background px-3 py-2 text-sm disabled:opacity-50"
value={cityCode} value={cityCode}
onChange={(e) => onCityChange(e.target.value)} onChange={(e) => onCityChange(e.target.value)}
disabled={!provinceCode} disabled={!provinceCode || cities.length === 0}
> >
<option value="">/</option> <option value="">
{cities.length === 0 ? "该省暂无下级城市" : "选择城市"}
</option>
{cities.map((c) => ( {cities.map((c) => (
<option key={c.code} value={c.code}> <option key={c.code} value={c.code}>
{c.name} {c.name}
@@ -53,6 +71,12 @@ export default function RegionSelect({
))} ))}
</select> </select>
</div> </div>
{cityOptional && provinceCode && (
<p className="text-xs text-muted-foreground">
使
{location ? `${location.name}${location.longitude}°)` : "—"}
</p>
)}
</div> </div>
); );
} }
+5 -1
View File
@@ -73,7 +73,11 @@
"longitude": 117.0009, "longitude": 117.0009,
"children": { "children": {
"370100": { "name": "济南市", "longitude": 117.1205 }, "370100": { "name": "济南市", "longitude": 117.1205 },
"370200": { "name": "青岛市", "longitude": 120.3826 } "370200": { "name": "青岛市", "longitude": 120.3826 },
"370300": { "name": "淄博市", "longitude": 118.0550 },
"370600": { "name": "烟台市", "longitude": 121.4479 },
"370700": { "name": "潍坊市", "longitude": 119.1619 },
"371300": { "name": "临沂市", "longitude": 118.3565 }
} }
}, },
"430000": { "430000": {
+12 -8
View File
@@ -11,10 +11,12 @@ export type RegionsData = Record<string, RegionNode>;
export const regions = regionsData as RegionsData; export const regions = regionsData as RegionsData;
export function getProvinces(): { code: string; name: string }[] { export function getProvinces(): { code: string; name: string }[] {
return Object.entries(regions).map(([code, node]) => ({ return Object.entries(regions)
code, .map(([code, node]) => ({
name: node.name, code,
})); name: node.name,
}))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
} }
export function getCities(provinceCode: string): { code: string; name: string }[] { export function getCities(provinceCode: string): { code: string; name: string }[] {
@@ -22,10 +24,12 @@ export function getCities(provinceCode: string): { code: string; name: string }[
if (!province?.children) { if (!province?.children) {
return []; return [];
} }
return Object.entries(province.children).map(([code, node]) => ({ return Object.entries(province.children)
code, .map(([code, node]) => ({
name: node.name, code,
})); name: node.name,
}))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
} }
export function getRegionLocation( export function getRegionLocation(