Implement three divination modes, learn pages, and PM2 deploy on port 3130.

Add liuyao/bazi/combined flows with shared calc and AI infrastructure, 64-gua learn routes, and update Ubuntu PM2 deployment docs for port 3130.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 20:19:49 +08:00
parent d141623070
commit fff77dac3f
41 changed files with 2590 additions and 385 deletions
+49
View File
@@ -0,0 +1,49 @@
"use client";
interface DateTimePickerProps {
date: string;
time: string;
onDateChange: (date: string) => void;
onTimeChange: (time: string) => void;
label?: string;
timeDisabled?: boolean;
}
export function nowDateString() {
return new Date().toISOString().slice(0, 10);
}
export function nowTimeString() {
const d = new Date();
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
}
export default function DateTimePicker({
date,
time,
onDateChange,
onTimeChange,
label = "时间",
timeDisabled = false,
}: DateTimePickerProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<div className="grid gap-2 sm:grid-cols-2">
<input
type="date"
className="rounded-md border bg-background px-3 py-2 text-sm disabled:opacity-50"
value={date}
onChange={(e) => onDateChange(e.target.value)}
/>
<input
type="time"
className="rounded-md border bg-background px-3 py-2 text-sm disabled:opacity-50"
value={time}
disabled={timeDisabled}
onChange={(e) => onTimeChange(e.target.value)}
/>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
import { bool } from "aimless.js";
import Coin from "@/components/coin";
import Hexagram from "@/components/hexagram";
import { Button } from "@/components/ui/button";
import {
buildHexagramList,
COIN_OPTIONS,
computeGuaResult,
type GuaResult,
YAO_LABELS,
} from "@/lib/calc/hexagram";
const AUTO_DELAY = 600;
interface HexagramInputProps {
mode: "online" | "offline";
enabled: boolean;
onResult: (data: GuaResult) => void;
onClear: () => void;
}
export default function HexagramInput({
mode,
enabled,
onResult,
onClear,
}: 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 [offlineCounts, setOfflineCounts] = useState<(number | null)[]>(
Array(6).fill(null),
);
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]);
function finishList(list: GuaResult["list"]) {
const result = computeGuaResult(list);
if (result) {
onResult({ list, result });
}
}
function onTransitionEnd() {
setRotation(false);
const frontCount = frontList.reduce(
(acc, val) => (val ? acc + 1 : acc),
0,
);
setHexagramList((list) => {
const newList = [
...list,
{
change: frontCount === 0 || frontCount === 3,
yang: frontCount >= 2,
separate: list.length === 3,
},
];
if (newList.length === 6) {
finishList(newList);
}
return newList;
});
}
function startOnlineCast() {
if (rotation || !enabled || hexagramList.length >= 6) {
return;
}
setFrontList([bool(), bool(), bool()]);
setRotation(true);
setCount((c) => c + 1);
}
function handleOfflineSelect(index: number, frontCount: number) {
const next = [...offlineCounts];
next[index] = frontCount;
setOfflineCounts(next);
}
function confirmOffline() {
if (offlineCounts.some((v) => v === null)) {
return;
}
const list = buildHexagramList(offlineCounts as number[]);
finishList(list);
setHexagramList(list);
}
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) {
return (
<p className="text-center text-sm text-muted-foreground">
</p>
);
}
return (
<div className="flex w-full flex-col items-center gap-4">
{mode === "online" && !complete && (
<>
<Coin
onTransitionEnd={onTransitionEnd}
frontList={frontList}
rotation={rotation}
/>
<span className="text-lg font-medium">
🎲 {" "}
<span className="font-mono text-xl font-bold text-orange-500">
{count === 0 ? "-/-" : `${count}/6`}
</span>{" "}
</span>
</>
)}
{mode === "offline" && !complete && (
<div className="w-full max-w-md space-y-2">
<p className="text-center text-xs text-muted-foreground">
</p>
{YAO_LABELS.map((label, index) => (
<div
key={label}
className="flex flex-wrap items-center gap-2 rounded-md border p-2"
>
<span className="w-10 shrink-0 text-sm font-medium">{label}</span>
{COIN_OPTIONS.map((opt) => (
<button
key={opt.count}
type="button"
onClick={() => handleOfflineSelect(index, opt.count)}
className={`rounded border px-2 py-1 text-xs transition ${
offlineCounts[index] === opt.count
? "border-primary bg-primary/10 text-primary"
: "hover:bg-accent"
}`}
>
{opt.label}
</button>
))}
</div>
))}
<Button
className="w-full"
disabled={!offlineReady}
onClick={confirmOffline}
>
</Button>
</div>
)}
{hexagramList.length > 0 && (
<div className="flex flex-col items-center gap-3 sm:flex-row">
<Hexagram list={hexagramList} />
{complete && (
<Button size="sm" variant="outline" onClick={handleReset}>
</Button>
)}
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { getProvinces, getCities, getRegionLocation } from "@/lib/data/regions";
interface RegionSelectProps {
provinceCode: string;
cityCode: string;
onProvinceChange: (code: string) => void;
onCityChange: (code: string) => void;
label?: string;
}
export default function RegionSelect({
provinceCode,
cityCode,
onProvinceChange,
onCityChange,
label = "出生地域",
}: RegionSelectProps) {
const provinces = getProvinces();
const cities = provinceCode ? getCities(provinceCode) : [];
return (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<div className="grid gap-2 sm:grid-cols-2">
<select
className="rounded-md border bg-background px-3 py-2 text-sm"
value={provinceCode}
onChange={(e) => {
onProvinceChange(e.target.value);
onCityChange("");
}}
>
<option value=""></option>
{provinces.map((p) => (
<option key={p.code} value={p.code}>
{p.name}
</option>
))}
</select>
<select
className="rounded-md border bg-background px-3 py-2 text-sm"
value={cityCode}
onChange={(e) => onCityChange(e.target.value)}
disabled={!provinceCode}
>
<option value="">/</option>
{cities.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
</div>
);
}
export function useRegionLocation(provinceCode: string, cityCode: string) {
return provinceCode
? getRegionLocation(provinceCode, cityCode)
: null;
}