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 clsx from "clsx";
import Image from "next/image";
const rotationDuration = 3800;
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: {
frontList: boolean[];
rotation: boolean;
onTransitionEnd: any;
onTransitionEnd: () => void;
}) {
const [lastFront, setLastFront] = useState(props.frontList);
useEffect(function () {
useEffect(() => {
if (!props.rotation) {
return;
}
let id = setTimeout(function () {
const id = setTimeout(() => {
setLastFront(props.frontList);
props.onTransitionEnd();
}, rotationDuration);
return () => clearTimeout(id);
});
}, [props.rotation, props.frontList, props.onTransitionEnd]);
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">
@@ -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: {
front: boolean;
lastFront: boolean;
rotation: boolean;
onTransitionEnd?: any;
}) {
let animate = "";
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(
props.front,
)}_${rotationDuration / 1000}s_${bezier}]`;
}
return (
<div
style={{
transform: `rotateY(${props.front ? 0 : 180}deg)`,
transformStyle: "preserve-3d",
transformOrigin: "50% 50% -0.5px",
}}
className={clsx("h-16 w-16 sm:h-20 sm:w-20", animate)}
className="h-16 w-16 sm:h-20 sm:w-20"
style={{ perspective: "800px" }}
>
<Image
width={0}
height={0}
sizes="100vw"
draggable={false}
className="absolute w-full"
src="/img/head.webp"
alt="coin"
/>
<Image
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
style={{
transform: `rotateY(${props.front ? 0 : 180}deg)`,
transformStyle: "preserve-3d",
}}
className={clsx("relative h-full w-full", animate)}
>
<CoinFace side="front" />
<CoinFace side="back" />
</div>
</div>
);
}
+27 -9
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { Compass, ListRestart } from "lucide-react";
import { BrainCircuit, ListRestart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import Result from "@/components/result";
@@ -35,18 +35,26 @@ export default function LiuyaoForm() {
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 "请选择起卦地域";
return "请选择起卦省份";
}
if (!guaData) {
return "请先完成起卦(线上摇卦或线下录入)";
return "请先完成 6 次起卦(线上摇卦或线下录入)";
}
return null;
}
@@ -115,6 +123,7 @@ export default function LiuyaoForm() {
setGuaData(data);
setShowAi(false);
setCompletion("");
setError("");
}
return (
@@ -197,16 +206,25 @@ export default function LiuyaoForm() {
/>
{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} />
</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>
)}
<div className="flex gap-2">
<div ref={actionRef} className="flex gap-2">
<Button variant="outline" onClick={handleReset} className="flex-1">
<ListRestart size={16} className="mr-1" />
@@ -216,8 +234,8 @@ export default function LiuyaoForm() {
disabled={!guaData || isLoading}
className="flex-1"
>
<Compass size={16} className="mr-1" />
<BrainCircuit size={16} className="mr-1" />
AI
</Button>
</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">
<Hexagram list={hexagramList} />
{complete && (
<Button size="sm" variant="outline" onClick={handleReset}>
</Button>
<>
<p className="text-sm text-muted-foreground sm:max-w-[10rem]">
AI
</p>
<Button size="sm" variant="outline" onClick={handleReset}>
</Button>
</>
)}
</div>
)}
+32 -8
View File
@@ -1,6 +1,10 @@
"use client";
import { getProvinces, getCities, getRegionLocation } from "@/lib/data/regions";
import {
getProvinces,
getCities,
getRegionLocation,
} from "@/lib/data/regions";
interface RegionSelectProps {
provinceCode: string;
@@ -8,6 +12,7 @@ interface RegionSelectProps {
onProvinceChange: (code: string) => void;
onCityChange: (code: string) => void;
label?: string;
cityOptional?: boolean;
}
export default function RegionSelect({
@@ -16,9 +21,23 @@ export default function RegionSelect({
onProvinceChange,
onCityChange,
label = "出生地域",
cityOptional = true,
}: RegionSelectProps) {
const provinces = getProvinces();
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 (
<div className="space-y-2">
@@ -27,10 +46,7 @@ export default function RegionSelect({
<select
className="rounded-md border bg-background px-3 py-2 text-sm"
value={provinceCode}
onChange={(e) => {
onProvinceChange(e.target.value);
onCityChange("");
}}
onChange={(e) => handleProvinceChange(e.target.value)}
>
<option value=""></option>
{provinces.map((p) => (
@@ -40,12 +56,14 @@ 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 disabled:opacity-50"
value={cityCode}
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) => (
<option key={c.code} value={c.code}>
{c.name}
@@ -53,6 +71,12 @@ export default function RegionSelect({
))}
</select>
</div>
{cityOptional && provinceCode && (
<p className="text-xs text-muted-foreground">
使
{location ? `${location.name}${location.longitude}°)` : "—"}
</p>
)}
</div>
);
}
+5 -1
View File
@@ -73,7 +73,11 @@
"longitude": 117.0009,
"children": {
"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": {
+12 -8
View File
@@ -11,10 +11,12 @@ export type RegionsData = Record<string, RegionNode>;
export const regions = regionsData as RegionsData;
export function getProvinces(): { code: string; name: string }[] {
return Object.entries(regions).map(([code, node]) => ({
code,
name: node.name,
}));
return Object.entries(regions)
.map(([code, node]) => ({
code,
name: node.name,
}))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
}
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) {
return [];
}
return Object.entries(province.children).map(([code, node]) => ({
code,
name: node.name,
}));
return Object.entries(province.children)
.map(([code, node]) => ({
code,
name: node.name,
}))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
}
export function getRegionLocation(