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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user