Fix learn 404, coin animation, full regions, and AI key errors.

Use numeric /learn/{num} routes, register Tailwind coin animations with方孔铜钱 UI, expand to 34 provinces, and surface missing OPENAI_API_KEY clearly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 21:32:20 +08:00
parent 96b659fbe5
commit a487b17165
15 changed files with 931 additions and 159 deletions
+23 -18
View File
@@ -2,7 +2,13 @@ import React, { useEffect, useState } from "react";
import clsx from "clsx";
const rotationDuration = 3800;
const bezier = "cubic-bezier(0.645,0.045,0.355,1)";
const COIN_ANIM: Record<string, string> = {
"front-front": "animate-coin-front-front",
"front-back": "animate-coin-front-back",
"back-front": "animate-coin-back-front",
"back-back": "animate-coin-back-back",
};
function Coin(props: {
frontList: boolean[];
@@ -15,7 +21,6 @@ function Coin(props: {
if (!props.rotation) {
return;
}
const id = setTimeout(() => {
setLastFront(props.frontList);
props.onTransitionEnd();
@@ -37,23 +42,30 @@ 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",
"absolute inset-0 rounded-full border-[3px] shadow-md",
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",
? "border-amber-600 bg-gradient-to-br from-amber-300 via-amber-400 to-amber-600"
: "border-stone-600 bg-gradient-to-br from-stone-400 via-stone-500 to-stone-700",
)}
style={{
backfaceVisibility: "hidden",
transform: isFront ? undefined : "rotateY(180deg)",
}}
>
<span className="select-none text-lg font-bold sm:text-xl">
{isFront ? "正" : "反"}
<div className="absolute inset-[18%] rounded-sm border-2 border-amber-800/40 bg-amber-900/10" />
<span
className={clsx(
"absolute bottom-1 left-0 right-0 text-center text-[10px] font-bold sm:text-xs",
isFront ? "text-amber-950/80" : "text-stone-200/90",
)}
>
{isFront ? "乾" : "坤"}
</span>
</div>
);
@@ -64,24 +76,17 @@ function CoinItem(props: {
lastFront: boolean;
rotation: boolean;
}) {
let animate = "";
if (props.rotation) {
animate = `animate-[coin-${getFront(props.lastFront)}-${getFront(
props.front,
)}_${rotationDuration / 1000}s_${bezier}]`;
}
const animKey = `${getFront(props.lastFront)}-${getFront(props.front)}`;
const animClass = props.rotation ? COIN_ANIM[animKey] : "";
return (
<div
className="h-16 w-16 sm:h-20 sm:w-20"
style={{ perspective: "800px" }}
>
<div className="h-[4.5rem] w-[4.5rem] sm:h-20 sm:w-20" style={{ perspective: "900px" }}>
<div
style={{
transform: `rotateY(${props.front ? 0 : 180}deg)`,
transformStyle: "preserve-3d",
}}
className={clsx("relative h-full w-full", animate)}
className={clsx("relative h-full w-full", animClass)}
>
<CoinFace side="front" />
<CoinFace side="back" />
+10 -2
View File
@@ -1,12 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Sparkles } from "lucide-react";
import { guaNumFromMark } from "@/lib/content/zhouyi";
export default function GuaFooter({ guaMark }: { guaMark: string }) {
export default function GuaFooter({
guaMark,
guaNum,
}: {
guaMark: string;
guaNum?: string;
}) {
const num = guaNum ?? guaNumFromMark(guaMark);
return (
<div className="mt-8 flex flex-wrap gap-3 border-t pt-6">
<Button asChild size="sm">
<Link href={`/liuyao?gua=${encodeURIComponent(guaMark)}`}>
<Link href={`/liuyao?gua=${num}`}>
<Sparkles size={16} className="mr-1" />
</Link>
+5 -2
View File
@@ -24,9 +24,12 @@ function resolveLearnHref(
if (href.endsWith("/index.md")) {
const mark = href.replace(/\/index\.md$/, "").replace(/^\.\//, "");
if (mark.startsWith("other/")) {
return `/learn/other/${mark.slice("other/".length)}`;
const folder = mark.slice("other/".length);
const num = folder.split(".")[0];
return `/learn/other/${num}`;
}
return `${base}/${mark}`;
const num = mark.split(".")[0];
return `${base}/${num}`;
}
return href;
}
+6 -3
View File
@@ -1,4 +1,6 @@
import React from "react";
import Link from "next/link";
import { guaNumFromMark } from "@/lib/content/zhouyi";
export interface ResultObj {
guaTitle: string;
@@ -8,16 +10,17 @@ export interface ResultObj {
}
function Result(props: ResultObj) {
const guaNum = guaNumFromMark(props.guaMark);
return (
<div className="flex flex-col items-start justify-center gap-2 sm:gap-3">
{props.guaTitle}
<a
<Link
className="group flex items-center gap-1 font-medium text-primary/80 underline underline-offset-4 transition-colors hover:text-primary/100"
href={`/learn/${props.guaMark}`}
href={`/learn/${guaNum}`}
>
<div className="mt-1 h-[90%] w-1.5 bg-blue-400/80 transition-colors group-hover:bg-blue-400/100" />
<span>{props.guaResult}</span>
</a>
</Link>
<span className="text-sm italic text-muted-foreground">
{props.guaChange}
</span>
+31 -36
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { bool } from "aimless.js";
import Coin from "@/components/coin";
import Hexagram from "@/components/hexagram";
@@ -13,7 +13,7 @@ import {
YAO_LABELS,
} from "@/lib/calc/hexagram";
const AUTO_DELAY = 600;
const AUTO_DELAY = 800;
interface HexagramInputProps {
mode: "online" | "offline";
@@ -30,43 +30,30 @@ export default function HexagramInput({
}: HexagramInputProps) {
const [frontList, setFrontList] = useState([true, true, true]);
const [rotation, setRotation] = useState(false);
const [hexagramList, setHexagramList] = useState<
GuaResult["list"]
>([]);
const [count, setCount] = useState(0);
const [hexagramList, setHexagramList] = useState<GuaResult["list"]>([]);
const [offlineCounts, setOfflineCounts] = useState<(number | null)[]>(
Array(6).fill(null),
);
const complete = hexagramList.length === 6;
useEffect(() => {
setHexagramList([]);
setCount(0);
setOfflineCounts(Array(6).fill(null));
setRotation(false);
}, [mode]);
useEffect(() => {
if (
mode !== "online" ||
!enabled ||
rotation ||
hexagramList.length >= 6 ||
count >= 6
) {
return;
}
const timer = setTimeout(startOnlineCast, AUTO_DELAY);
return () => clearTimeout(timer);
}, [mode, enabled, rotation, count, hexagramList.length]);
const finishList = useCallback(
(list: GuaResult["list"]) => {
const result = computeGuaResult(list);
if (result) {
onResult({ list, result });
}
},
[onResult],
);
function finishList(list: GuaResult["list"]) {
const result = computeGuaResult(list);
if (result) {
onResult({ list, result });
}
}
function onTransitionEnd() {
const onTransitionEnd = useCallback(() => {
setRotation(false);
const frontCount = frontList.reduce(
(acc, val) => (val ? acc + 1 : acc),
@@ -86,16 +73,23 @@ export default function HexagramInput({
}
return newList;
});
}
}, [frontList, finishList]);
function startOnlineCast() {
const startOnlineCast = useCallback(() => {
if (rotation || !enabled || hexagramList.length >= 6) {
return;
}
setFrontList([bool(), bool(), bool()]);
setRotation(true);
setCount((c) => c + 1);
}
}, [rotation, enabled, hexagramList.length]);
useEffect(() => {
if (mode !== "online" || !enabled || rotation || complete) {
return;
}
const timer = setTimeout(startOnlineCast, AUTO_DELAY);
return () => clearTimeout(timer);
}, [mode, enabled, rotation, complete, hexagramList.length, startOnlineCast]);
function handleOfflineSelect(index: number, frontCount: number) {
const next = [...offlineCounts];
@@ -114,13 +108,11 @@ export default function HexagramInput({
function handleReset() {
setHexagramList([]);
setCount(0);
setOfflineCounts(Array(6).fill(null));
setRotation(false);
onClear();
}
const complete = hexagramList.length === 6;
const offlineReady = offlineCounts.every((v) => v !== null);
if (!enabled) {
@@ -133,7 +125,7 @@ export default function HexagramInput({
return (
<div className="flex w-full flex-col items-center gap-4">
{mode === "online" && !complete && (
{mode === "online" && (
<>
<Coin
onTransitionEnd={onTransitionEnd}
@@ -143,9 +135,12 @@ export default function HexagramInput({
<span className="text-lg font-medium">
🎲 {" "}
<span className="font-mono text-xl font-bold text-orange-500">
{count === 0 ? "-/-" : `${count}/6`}
{complete ? "6/6" : `${hexagramList.length + (rotation ? 1 : 0)}/6`}
</span>{" "}
{rotation && (
<span className="ml-2 text-sm text-muted-foreground"></span>
)}
</span>
</>
)}
+3 -3
View File
@@ -71,10 +71,10 @@ export default function RegionSelect({
))}
</select>
</div>
{cityOptional && provinceCode && (
{cityOptional && provinceCode && location && (
<p className="text-xs text-muted-foreground">
使
{location ? `${location.name}${location.longitude}°)` : "—"}
使
{location.name}{location.longitude}°
</p>
)}
</div>