+
+
+
-
知命阁
+
知命阁
+
+
+
+
-
);
diff --git a/components/learn/gua-footer.tsx b/components/learn/gua-footer.tsx
new file mode 100644
index 0000000..cef577f
--- /dev/null
+++ b/components/learn/gua-footer.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Sparkles } from "lucide-react";
+
+export default function GuaFooter({ guaMark }: { guaMark: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/components/learn/markdown-content.tsx b/components/learn/markdown-content.tsx
new file mode 100644
index 0000000..9766e1c
--- /dev/null
+++ b/components/learn/markdown-content.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+import Markdown from "react-markdown";
+import type { LearnVariant } from "@/lib/content/zhouyi";
+
+function resolveLearnHref(
+ href: string | undefined,
+ variant: LearnVariant,
+): string | undefined {
+ if (!href) {
+ return href;
+ }
+ if (href.startsWith("http://") || href.startsWith("https://")) {
+ return href;
+ }
+
+ const base = variant === "traditional" ? "/learn" : "/learn/other";
+
+ if (href === "index.md" || href === "./index.md") {
+ return base;
+ }
+ if (href === "other/index.md") {
+ return "/learn/other";
+ }
+ if (href.endsWith("/index.md")) {
+ const mark = href.replace(/\/index\.md$/, "").replace(/^\.\//, "");
+ if (mark.startsWith("other/")) {
+ return `/learn/other/${mark.slice("other/".length)}`;
+ }
+ return `${base}/${mark}`;
+ }
+ return href;
+}
+
+export default function MarkdownContent({
+ content,
+ variant = "traditional",
+}: {
+ content: string;
+ variant?: LearnVariant;
+}) {
+ return (
+
{
+ const resolved = resolveLearnHref(href, variant);
+ if (resolved?.startsWith("/")) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ img: ({ src, alt, ...props }) => {
+ if (typeof src === "string" && !src.startsWith("http")) {
+ return (
+
+ [{alt || "卦象图片"}]
+
+ );
+ }
+ return
;
+ },
+ }}
+ >
+ {content}
+
+ );
+}
diff --git a/components/modes/bazi-chart.tsx b/components/modes/bazi-chart.tsx
new file mode 100644
index 0000000..7305a55
--- /dev/null
+++ b/components/modes/bazi-chart.tsx
@@ -0,0 +1,72 @@
+import type { BaziChart, PillarInfo } from "@/lib/calc/bazi";
+
+function PillarRow({ label, pillar }: { label: string; pillar: PillarInfo }) {
+ return (
+
+ | {label} |
+ {pillar.ganZhi} |
+ {pillar.shiShenGan} |
+
+ {pillar.shiShenZhi.join("、") || "—"}
+ |
+ {pillar.naYin} |
+
+ );
+}
+
+export default function BaziChartDisplay({ chart }: { chart: BaziChart }) {
+ return (
+
+
+ 出生:{chart.birthTime}
+ 真太阳时:{chart.trueSolarTime}
+ 农历:{chart.lunarDate}
+
+
+
+
+
+
+ | 柱 |
+ 干支 |
+ 天干十神 |
+ 地支十神 |
+ 纳音 |
+
+
+
+
+
+
+
+
+
+
+
+
+
大运
+
+ 起运 {chart.daYun.startYear} 年 {chart.daYun.startMonth} 月{" "}
+ {chart.daYun.startDay} 天 ·{" "}
+ {chart.daYun.items.map((d) => `${d.startAge}岁 ${d.ganZhi}`).join(" → ")}
+
+
+
+
+
流年
+
+ {chart.liuNian.map((l) => `${l.year}(${l.ganZhi})`).join("、")}
+
+
+
+
+
神煞
+
+ 吉神:{chart.shenSha.ji.join("、") || "无"}
+
+ 凶煞:{chart.shenSha.xiong.join("、") || "无"}
+
+
+
+ );
+}
diff --git a/components/modes/bazi-form.tsx b/components/modes/bazi-form.tsx
new file mode 100644
index 0000000..674e770
--- /dev/null
+++ b/components/modes/bazi-form.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { useState } from "react";
+import { readStreamableValue } from "ai/rsc";
+import { BrainCircuit } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import ResultAI from "@/components/result-ai";
+import BaziChartDisplay from "@/components/modes/bazi-chart";
+import DateTimePicker, { nowDateString } from "@/components/shared/datetime-picker";
+import RegionSelect, {
+ useRegionLocation,
+} from "@/components/shared/region-select";
+import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
+import { getBaziAnswer } from "@/app/actions/bazi";
+import { ERROR_PREFIX } from "@/lib/constant";
+
+export default function BaziForm() {
+ const [date, setDate] = useState(nowDateString());
+ const [time, setTime] = useState("12:00");
+ const [unknownHour, setUnknownHour] = useState(false);
+ const [gender, setGender] = useState<"male" | "female">("male");
+ const [provinceCode, setProvinceCode] = useState("");
+ const [cityCode, setCityCode] = useState("");
+ const [question, setQuestion] = useState("");
+ const [chart, setChart] = useState
(null);
+ const [completion, setCompletion] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [showAi, setShowAi] = useState(false);
+
+ const location = useRegionLocation(provinceCode, cityCode);
+
+ function buildInput() {
+ if (!location) {
+ return null;
+ }
+ return {
+ date,
+ time: unknownHour ? "12:00" : time,
+ gender,
+ longitude: location.longitude,
+ unknownHour,
+ };
+ }
+
+ function handleCalculate() {
+ const input = buildInput();
+ if (!input) {
+ setError("请选择出生地域");
+ return;
+ }
+ if (!question.trim()) {
+ setError("请输入问事");
+ return;
+ }
+ setError("");
+ setChart(calculateBazi(input));
+ setShowAi(false);
+ setCompletion("");
+ }
+
+ async function handleAnalyze() {
+ const input = buildInput();
+ if (!input || !chart) {
+ return;
+ }
+ setError("");
+ setCompletion("");
+ setIsLoading(true);
+ setShowAi(true);
+ try {
+ const { data, error: apiError } = await getBaziAnswer(
+ input,
+ question,
+ location!.name,
+ );
+ if (apiError) {
+ setError(apiError);
+ return;
+ }
+ if (data) {
+ let ret = "";
+ for await (const delta of readStreamableValue(data)) {
+ if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
+ setError(delta.slice(ERROR_PREFIX.length));
+ return;
+ }
+ ret += delta ?? "";
+ setCompletion(ret);
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {(["male", "female"] as const).map((g) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {error && !showAi && (
+
{error}
+ )}
+
+
+
+
+ {chart && (
+
+
+ {!showAi && (
+
+ )}
+
+ )}
+
+ {showAi && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/modes/combined-form.tsx b/components/modes/combined-form.tsx
new file mode 100644
index 0000000..ea8e4c3
--- /dev/null
+++ b/components/modes/combined-form.tsx
@@ -0,0 +1,325 @@
+"use client";
+
+import { useState } from "react";
+import { readStreamableValue } from "ai/rsc";
+import { Compass } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import Result from "@/components/result";
+import ResultAI from "@/components/result-ai";
+import BaziChartDisplay from "@/components/modes/bazi-chart";
+import DateTimePicker, {
+ nowDateString,
+ nowTimeString,
+} from "@/components/shared/datetime-picker";
+import HexagramInput from "@/components/shared/hexagram-input";
+import RegionSelect, {
+ useRegionLocation,
+} from "@/components/shared/region-select";
+import { calculateBazi, type BaziChart } from "@/lib/calc/bazi";
+import type { GuaResult } from "@/lib/calc/hexagram";
+import { getCombinedAnswer } from "@/app/actions/combined";
+import { ERROR_PREFIX } from "@/lib/constant";
+
+export default function CombinedForm() {
+ const [birthDate, setBirthDate] = useState("1990-01-01");
+ const [birthTime, setBirthTime] = useState("12:00");
+ const [unknownHour, setUnknownHour] = useState(false);
+ const [gender, setGender] = useState<"male" | "female">("male");
+ const [birthProvince, setBirthProvince] = useState("");
+ const [birthCity, setBirthCity] = useState("");
+ const [currentProvince, setCurrentProvince] = useState("");
+ const [currentCity, setCurrentCity] = useState("");
+ const [calcDate, setCalcDate] = useState(nowDateString);
+ const [calcTime, setCalcTime] = useState(nowTimeString);
+ const [question, setQuestion] = useState("");
+ const [withHexagram, setWithHexagram] = useState(false);
+ const [castMode, setCastMode] = useState<"online" | "offline">("online");
+ const [guaData, setGuaData] = useState(null);
+
+ const [chart, setChart] = useState(null);
+ const [completion, setCompletion] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [showAi, setShowAi] = useState(false);
+
+ const birthLocation = useRegionLocation(birthProvince, birthCity);
+ const currentLocation = useRegionLocation(currentProvince, currentCity);
+ const hexagramReady =
+ !withHexagram ||
+ (question.trim() !== "" && currentLocation !== null && guaData !== null);
+
+ function validate(): string | null {
+ if (!birthLocation) {
+ return "请选择出生地域";
+ }
+ if (!currentLocation) {
+ return "请选择当前所在地域";
+ }
+ if (!question.trim()) {
+ return "请输入问事内容";
+ }
+ if (withHexagram && !guaData) {
+ return "请完成六爻起卦,或取消附加六爻";
+ }
+ return null;
+ }
+
+ function handlePreview() {
+ const err = validate();
+ if (err) {
+ setError(err);
+ return;
+ }
+ setError("");
+ setChart(
+ calculateBazi({
+ date: birthDate,
+ time: unknownHour ? "12:00" : birthTime,
+ gender,
+ longitude: birthLocation!.longitude,
+ unknownHour,
+ }),
+ );
+ setShowAi(false);
+ setCompletion("");
+ }
+
+ async function handleAnalyze() {
+ const err = validate();
+ if (err) {
+ setError(err);
+ return;
+ }
+ if (!chart) {
+ setChart(
+ calculateBazi({
+ date: birthDate,
+ time: unknownHour ? "12:00" : birthTime,
+ gender,
+ longitude: birthLocation!.longitude,
+ unknownHour,
+ }),
+ );
+ }
+
+ setError("");
+ setCompletion("");
+ setIsLoading(true);
+ setShowAi(true);
+
+ try {
+ const { data, error: apiError } = await getCombinedAnswer({
+ birth: {
+ date: birthDate,
+ time: unknownHour ? "12:00" : birthTime,
+ gender,
+ longitude: birthLocation!.longitude,
+ unknownHour,
+ },
+ birthPlaceName: birthLocation!.name,
+ currentPlaceName: currentLocation!.name,
+ currentLongitude: currentLocation!.longitude,
+ calcDate,
+ calcTime,
+ question,
+ hexagram: withHexagram && guaData
+ ? {
+ guaMark: guaData.result.guaMark,
+ guaTitle: guaData.result.guaTitle,
+ guaResult: guaData.result.guaResult,
+ guaChange: guaData.result.guaChange,
+ }
+ : undefined,
+ });
+
+ if (apiError) {
+ setError(apiError);
+ return;
+ }
+ if (data) {
+ let ret = "";
+ for await (const delta of readStreamableValue(data)) {
+ if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
+ setError(delta.slice(ERROR_PREFIX.length));
+ return;
+ }
+ ret += delta ?? "";
+ setCompletion(ret);
+ }
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {withHexagram && (
+
+
+
+
+
+
setGuaData(null)}
+ />
+ {guaData && (
+
+
+
+ )}
+
+ )}
+
+
+ {error && !showAi && (
+
{error}
+ )}
+
+
+
+
+
+
+
+ {chart && (
+
+
+
+ )}
+
+ {showAi && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/modes/liuyao-form.tsx b/components/modes/liuyao-form.tsx
new file mode 100644
index 0000000..223ccda
--- /dev/null
+++ b/components/modes/liuyao-form.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import { useState } from "react";
+import { readStreamableValue } from "ai/rsc";
+import { Compass, ListRestart } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import Result from "@/components/result";
+import ResultAI from "@/components/result-ai";
+import DateTimePicker, {
+ nowDateString,
+ nowTimeString,
+} from "@/components/shared/datetime-picker";
+import HexagramInput from "@/components/shared/hexagram-input";
+import RegionSelect, {
+ useRegionLocation,
+} from "@/components/shared/region-select";
+import { getLiuyaoAnswer } from "@/app/actions/liuyao";
+import type { GuaResult } from "@/lib/calc/hexagram";
+import { ERROR_PREFIX } from "@/lib/constant";
+import todayJson from "@/lib/data/today.json";
+
+const todayData: string[] = todayJson;
+
+export default function LiuyaoForm() {
+ const [question, setQuestion] = useState("");
+ const [provinceCode, setProvinceCode] = useState("");
+ const [cityCode, setCityCode] = useState("");
+ const [calcDate, setCalcDate] = useState(nowDateString);
+ const [calcTime, setCalcTime] = useState(nowTimeString);
+ const [castMode, setCastMode] = useState<"online" | "offline">("online");
+ const [guaData, setGuaData] = useState(null);
+ const [completion, setCompletion] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [showAi, setShowAi] = useState(false);
+
+ const location = useRegionLocation(provinceCode, cityCode);
+ const formReady = question.trim() !== "" && location !== null;
+
+ function validate(): string | null {
+ if (!question.trim()) {
+ return "请输入问事";
+ }
+ if (!location) {
+ return "请选择起卦地域";
+ }
+ if (!guaData) {
+ return "请先完成起卦(线上摇卦或线下录入)";
+ }
+ return null;
+ }
+
+ async function handleAnalyze() {
+ const err = validate();
+ if (err) {
+ setError(err);
+ return;
+ }
+
+ setError("");
+ setCompletion("");
+ setIsLoading(true);
+ setShowAi(true);
+
+ try {
+ const { data, error: apiError } = await getLiuyaoAnswer({
+ question,
+ calcDate,
+ calcTime,
+ locationName: location!.name,
+ longitude: location!.longitude,
+ guaMark: guaData!.result.guaMark,
+ guaTitle: guaData!.result.guaTitle,
+ guaResult: guaData!.result.guaResult,
+ guaChange: guaData!.result.guaChange,
+ });
+
+ if (apiError) {
+ setError(apiError);
+ return;
+ }
+ if (data) {
+ let ret = "";
+ for await (const delta of readStreamableValue(data)) {
+ if (typeof delta === "string" && delta.startsWith(ERROR_PREFIX)) {
+ setError(delta.slice(ERROR_PREFIX.length));
+ return;
+ }
+ ret += delta ?? "";
+ setCompletion(ret);
+ }
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ function handleReset() {
+ setQuestion("");
+ setProvinceCode("");
+ setCityCode("");
+ setCalcDate(nowDateString());
+ setCalcTime(nowTimeString());
+ setGuaData(null);
+ setCompletion("");
+ setError("");
+ setShowAi(false);
+ setIsLoading(false);
+ }
+
+ function handleGuaResult(data: GuaResult) {
+ setGuaData(data);
+ setShowAi(false);
+ setCompletion("");
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setGuaData(null)}
+ />
+
+ {guaData && (
+
+
+
+ )}
+
+ {error && !showAi && (
+ {error}
+ )}
+
+
+
+
+
+
+
+ {showAi && (
+
+
+
+ )}
+
+ );
+}
diff --git a/components/page-shell.tsx b/components/page-shell.tsx
new file mode 100644
index 0000000..a1f9978
--- /dev/null
+++ b/components/page-shell.tsx
@@ -0,0 +1,20 @@
+import Header from "@/components/header";
+import Footer from "@/components/footer";
+
+export default function PageShell({
+ children,
+ className = "",
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/components/shared/datetime-picker.tsx b/components/shared/datetime-picker.tsx
new file mode 100644
index 0000000..e7db1c9
--- /dev/null
+++ b/components/shared/datetime-picker.tsx
@@ -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 (
+
+ );
+}
diff --git a/components/shared/hexagram-input.tsx b/components/shared/hexagram-input.tsx
new file mode 100644
index 0000000..871b7a9
--- /dev/null
+++ b/components/shared/hexagram-input.tsx
@@ -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 (
+
+ 请先填写问事、地域和起卦时辰
+
+ );
+ }
+
+ return (
+
+ {mode === "online" && !complete && (
+ <>
+
+
+ 🎲 第{" "}
+
+ {count === 0 ? "-/-" : `${count}/6`}
+ {" "}
+ 次卜筮
+
+ >
+ )}
+
+ {mode === "offline" && !complete && (
+
+
+ 人工抛铜钱后,按从下到上(初爻→上爻)录入每爻结果
+
+ {YAO_LABELS.map((label, index) => (
+
+ {label}
+ {COIN_OPTIONS.map((opt) => (
+
+ ))}
+
+ ))}
+
+
+ )}
+
+ {hexagramList.length > 0 && (
+
+
+ {complete && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/components/shared/region-select.tsx b/components/shared/region-select.tsx
new file mode 100644
index 0000000..e2c9558
--- /dev/null
+++ b/components/shared/region-select.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
+
+export function useRegionLocation(provinceCode: string, cityCode: string) {
+ return provinceCode
+ ? getRegionLocation(provinceCode, cityCode)
+ : null;
+}
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 255d7fa..24e0e1c 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -16,7 +16,7 @@
│
┌────────▼────────┐
│ PM2 │
- │ zhimingge │ :3000
+ │ zhimingge │ :3130
└────────┬────────┘
│
┌────────▼────────┐
@@ -38,7 +38,7 @@
| 安装目录 | `/opt/zhimingge` |
| 进程管理 | PM2 |
| 构建模式 | Next.js `output: "standalone"` |
-| 默认端口 | 3000 |
+| 默认端口 | **3130** |
---
@@ -172,8 +172,8 @@ OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=https://op.bz121.com/v1
OPENAI_MODEL=huihui_ai/gemma-4-abliterated:e4b
-# 服务端口
-PORT=3000
+# 服务端口(与 PM2 ecosystem.config.cjs 一致)
+PORT=3130
NODE_ENV=production
```
@@ -247,7 +247,7 @@ pm2 delete zhimingge # 删除进程
```bash
cd /opt/zhimingge/.next/standalone
-PORT=3000 node server.js
+PORT=3130 node server.js
```
对应 PM2 配置片段:
@@ -258,7 +258,7 @@ PORT=3000 node server.js
cwd: "/opt/zhimingge/.next/standalone",
script: "server.js",
env: {
- PORT: 3000,
+ PORT: 3130,
NODE_ENV: "production",
},
}
@@ -269,11 +269,11 @@ PORT=3000 node server.js
### 7.6 验证
```bash
-curl -I http://127.0.0.1:3000
+curl -I http://127.0.0.1:3130
# 应返回 HTTP/1.1 200
```
-浏览器访问:`http://服务器IP:3000`
+浏览器访问:`http://服务器IP:3130`
---
@@ -291,7 +291,7 @@ server {
server_name zhimingge.example.com;
location / {
- proxy_pass http://127.0.0.1:3000;
+ proxy_pass http://127.0.0.1:3130;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -359,8 +359,8 @@ ufw allow 80
ufw allow 443
ufw enable
-# 若不用 Nginx,直接暴露 3000
-ufw allow 3000
+# 若不用 Nginx,直接暴露 3130
+ufw allow 3130
```
---
@@ -446,7 +446,7 @@ pm2 save && pm2 startup
cd /opt/zhimingge && git pull && pnpm install && pnpm run build && pm2 restart zhimingge
# 查看状态
-pm2 status && curl -I http://127.0.0.1:3000
+pm2 status && curl -I http://127.0.0.1:3130
```
---
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 53c71ed..9261243 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -341,7 +341,7 @@ OPENAI_MODEL=huihui_ai/gemma-4-abliterated:e4b
| `OPENAI_API_KEY` | 是 | — | AI 接口密钥 |
| `OPENAI_BASE_URL` | 否 | `https://op.bz121.com/v1` | API 地址 |
| `OPENAI_MODEL` | 否 | `huihui_ai/gemma-4-abliterated:e4b` | 模型名 |
-| `PORT` | 否 | `3000` | 服务端口 |
+| `PORT` | 否 | `3130` | 服务端口 |
| `NODE_ENV` | 否 | `production` | 运行环境 |
| `UMAMI_ID` | 否 | — | 访问统计(可选,可不用) |
| `UMAMI_URL` | 否 | — | 统计脚本地址(可选) |
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
index b88133c..7640c49 100644
--- a/ecosystem.config.cjs
+++ b/ecosystem.config.cjs
@@ -1,9 +1,11 @@
/**
* PM2 进程配置 — 知命阁(zhimingge)
* 部署目录:/opt/zhimingge
+ * 生产端口:3130
*
- * 启动:pm2 start ecosystem.config.cjs
+ * 首次:pm2 start ecosystem.config.cjs
* 重启:pm2 restart zhimingge
+ * 日志:pm2 logs zhimingge
*/
module.exports = {
apps: [
@@ -11,7 +13,7 @@ module.exports = {
name: "zhimingge",
cwd: "/opt/zhimingge",
script: "node_modules/next/dist/bin/next",
- args: "start",
+ args: "start -p 3130",
instances: 1,
exec_mode: "fork",
autorestart: true,
@@ -19,7 +21,7 @@ module.exports = {
max_memory_restart: "512M",
env: {
NODE_ENV: "production",
- PORT: 3000,
+ PORT: 3130,
},
error_file: "/opt/zhimingge/logs/pm2-error.log",
out_file: "/opt/zhimingge/logs/pm2-out.log",
diff --git a/lib/ai/stream.ts b/lib/ai/stream.ts
new file mode 100644
index 0000000..8d829bd
--- /dev/null
+++ b/lib/ai/stream.ts
@@ -0,0 +1,77 @@
+"use server";
+
+import { streamText } from "ai";
+import { createOpenAI } from "@ai-sdk/openai";
+import { createStreamableValue } from "ai/rsc";
+import { ERROR_PREFIX } from "@/lib/constant";
+
+const model =
+ process.env.OPENAI_MODEL ?? "huihui_ai/gemma-4-abliterated:e4b";
+const openai = createOpenAI({
+ baseURL: process.env.OPENAI_BASE_URL ?? "https://op.bz121.com/v1",
+});
+
+const STREAM_INTERVAL = 60;
+const MAX_SIZE = 6;
+
+export async function streamAIResponse(
+ system: string,
+ user: string,
+): Promise<{ data?: ReturnType>["value"]; error?: string }> {
+ const stream = createStreamableValue();
+
+ try {
+ const { fullStream } = streamText({
+ temperature: 0.5,
+ model: openai(model),
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: user },
+ ],
+ maxRetries: 0,
+ });
+
+ let buffer = "";
+ let done = false;
+ const intervalId = setInterval(() => {
+ if (done && buffer.length === 0) {
+ clearInterval(intervalId);
+ stream.done();
+ return;
+ }
+ if (buffer.length <= MAX_SIZE) {
+ stream.update(buffer);
+ buffer = "";
+ } else {
+ const chunk = buffer.slice(0, MAX_SIZE);
+ buffer = buffer.slice(MAX_SIZE);
+ stream.update(chunk);
+ }
+ }, STREAM_INTERVAL);
+
+ (async () => {
+ for await (const part of fullStream) {
+ switch (part.type) {
+ case "text-delta":
+ buffer += part.textDelta;
+ break;
+ case "error": {
+ const err = part.error as { message?: string };
+ stream.update(ERROR_PREFIX + (err.message ?? String(part.error)));
+ break;
+ }
+ }
+ }
+ })()
+ .catch(console.error)
+ .finally(() => {
+ done = true;
+ });
+
+ return { data: stream.value };
+ } catch (err) {
+ stream.done();
+ const message = err instanceof Error ? err.message : String(err);
+ return { error: message };
+ }
+}
diff --git a/lib/calc/bazi.ts b/lib/calc/bazi.ts
new file mode 100644
index 0000000..f3a5785
--- /dev/null
+++ b/lib/calc/bazi.ts
@@ -0,0 +1,176 @@
+import { Solar } from "lunar-javascript";
+import {
+ adjustToTrueSolarTime,
+ formatDateTime,
+ parseDateTime,
+} from "@/lib/calc/time";
+
+export interface BaziInput {
+ date: string;
+ time: string;
+ gender: "male" | "female";
+ longitude: number;
+ unknownHour?: boolean;
+}
+
+export interface PillarInfo {
+ ganZhi: string;
+ shiShenGan: string;
+ shiShenZhi: string[];
+ hideGan: string[];
+ naYin: string;
+}
+
+export interface DaYunInfo {
+ ganZhi: string;
+ startAge: number;
+}
+
+export interface BaziChart {
+ birthTime: string;
+ trueSolarTime: string;
+ unknownHour: boolean;
+ lunarDate: string;
+ pillars: {
+ year: PillarInfo;
+ month: PillarInfo;
+ day: PillarInfo;
+ time: PillarInfo;
+ };
+ daYun: {
+ startYear: number;
+ startMonth: number;
+ startDay: number;
+ items: DaYunInfo[];
+ };
+ liuNian: { year: number; ganZhi: string }[];
+ shenSha: {
+ ji: string[];
+ xiong: string[];
+ };
+}
+
+function buildPillar(
+ ganZhi: string,
+ shiShenGan: string,
+ shiShenZhi: string[],
+ hideGan: string[],
+ naYin: string,
+): PillarInfo {
+ return { ganZhi, shiShenGan, shiShenZhi, hideGan, naYin };
+}
+
+export function calculateBazi(input: BaziInput): BaziChart {
+ const localTime = parseDateTime(input.date, input.time);
+ const trueSolar = adjustToTrueSolarTime(localTime, input.longitude);
+
+ const solar = Solar.fromYmdHms(
+ trueSolar.getFullYear(),
+ trueSolar.getMonth() + 1,
+ trueSolar.getDate(),
+ input.unknownHour ? 12 : trueSolar.getHours(),
+ input.unknownHour ? 0 : trueSolar.getMinutes(),
+ 0,
+ );
+
+ const lunar = solar.getLunar();
+ const ec = lunar.getEightChar();
+ const genderCode = input.gender === "male" ? 1 : 0;
+ const yun = ec.getYun(genderCode);
+
+ const currentYear = new Date().getFullYear();
+ const liuNian: { year: number; ganZhi: string }[] = [];
+ for (let year = currentYear - 2; year <= currentYear + 3; year++) {
+ const yearSolar = Solar.fromYmdHms(year, 6, 1, 12, 0, 0);
+ liuNian.push({
+ year,
+ ganZhi: yearSolar.getLunar().getYearInGanZhi(),
+ });
+ }
+
+ const daYunList = yun.getDaYun();
+ const daYunItems: DaYunInfo[] = [];
+ let age = yun.getStartYear();
+ for (const dy of daYunList) {
+ const gz = dy.getGanZhi();
+ if (!gz) {
+ continue;
+ }
+ daYunItems.push({ ganZhi: gz, startAge: age });
+ age += 10;
+ if (daYunItems.length >= 8) {
+ break;
+ }
+ }
+
+ return {
+ birthTime: formatDateTime(localTime),
+ trueSolarTime: formatDateTime(trueSolar),
+ unknownHour: !!input.unknownHour,
+ lunarDate: lunar.toString(),
+ pillars: {
+ year: buildPillar(
+ ec.getYear(),
+ ec.getYearShiShenGan(),
+ ec.getYearShiShenZhi(),
+ ec.getYearHideGan(),
+ ec.getYearNaYin(),
+ ),
+ month: buildPillar(
+ ec.getMonth(),
+ ec.getMonthShiShenGan(),
+ ec.getMonthShiShenZhi(),
+ ec.getMonthHideGan(),
+ ec.getMonthNaYin(),
+ ),
+ day: buildPillar(
+ ec.getDay(),
+ ec.getDayShiShenGan(),
+ ec.getDayShiShenZhi(),
+ ec.getDayHideGan(),
+ ec.getDayNaYin(),
+ ),
+ time: buildPillar(
+ input.unknownHour ? "不详" : ec.getTime(),
+ input.unknownHour ? "—" : ec.getTimeShiShenGan(),
+ input.unknownHour ? [] : ec.getTimeShiShenZhi(),
+ input.unknownHour ? [] : ec.getTimeHideGan(),
+ input.unknownHour ? "—" : ec.getTimeNaYin(),
+ ),
+ },
+ daYun: {
+ startYear: yun.getStartYear(),
+ startMonth: yun.getStartMonth(),
+ startDay: yun.getStartDay(),
+ items: daYunItems,
+ },
+ liuNian,
+ shenSha: {
+ ji: lunar.getDayJiShen(),
+ xiong: lunar.getDayXiongSha(),
+ },
+ };
+}
+
+export function formatBaziForPrompt(chart: BaziChart): string {
+ const p = chart.pillars;
+ const lines = [
+ `出生时间:${chart.birthTime}`,
+ `真太阳时:${chart.trueSolarTime}${chart.unknownHour ? "(时辰不详,按中午排盘)" : ""}`,
+ `农历:${chart.lunarDate}`,
+ "",
+ "【四柱】",
+ `年柱:${p.year.ganZhi}(天干十神:${p.year.shiShenGan},地支十神:${p.year.shiShenZhi.join("、")},藏干:${p.year.hideGan.join("、")},纳音:${p.year.naYin})`,
+ `月柱:${p.month.ganZhi}(天干十神:${p.month.shiShenGan},地支十神:${p.month.shiShenZhi.join("、")},藏干:${p.month.hideGan.join("、")},纳音:${p.month.naYin})`,
+ `日柱:${p.day.ganZhi}(天干十神:${p.day.shiShenGan},地支十神:${p.day.shiShenZhi.join("、")},藏干:${p.day.hideGan.join("、")},纳音:${p.day.naYin})`,
+ `时柱:${p.time.ganZhi}(天干十神:${p.time.shiShenGan},地支十神:${p.time.shiShenZhi.join("、") || "—"},藏干:${p.time.hideGan.join("、") || "—"},纳音:${p.time.naYin})`,
+ "",
+ `【大运】起运 ${chart.daYun.startYear} 年 ${chart.daYun.startMonth} 月 ${chart.daYun.startDay} 天`,
+ chart.daYun.items.map((d) => `${d.startAge}岁起 ${d.ganZhi}`).join(" → "),
+ "",
+ `【流年】${chart.liuNian.map((l) => `${l.year}(${l.ganZhi})`).join("、")}`,
+ "",
+ `【神煞】吉神:${chart.shenSha.ji.join("、") || "无"};凶煞:${chart.shenSha.xiong.join("、") || "无"}`,
+ ];
+ return lines.join("\n");
+}
diff --git a/lib/calc/hexagram.ts b/lib/calc/hexagram.ts
new file mode 100644
index 0000000..2a2d6d0
--- /dev/null
+++ b/lib/calc/hexagram.ts
@@ -0,0 +1,77 @@
+import guaIndexData from "@/lib/data/gua-index.json";
+import guaListData from "@/lib/data/gua-list.json";
+import type { HexagramObj } from "@/components/hexagram";
+import type { ResultObj } from "@/components/result";
+
+const GUA_DICT1 = ["坤", "震", "坎", "兑", "艮", "离", "巽", "乾"];
+const GUA_DICT2 = ["地", "雷", "水", "泽", "山", "火", "风", "天"];
+const CHANGE_YANG = ["初九", "九二", "九三", "九四", "九五", "上九"];
+const CHANGE_YIN = ["初六", "六二", "六三", "六四", "六五", "上六"];
+
+/** 三钱法:正面数 0~3 → 爻象 */
+export function frontCountToLine(frontCount: number): Omit {
+ return {
+ change: frontCount === 0 || frontCount === 3,
+ yang: frontCount >= 2,
+ };
+}
+
+export function buildHexagramList(frontCounts: number[]): HexagramObj[] {
+ return frontCounts.map((count, index) => ({
+ ...frontCountToLine(count),
+ separate: index === 3,
+ }));
+}
+
+export function computeGuaResult(list: HexagramObj[]): ResultObj | null {
+ if (list.length !== 6) {
+ return null;
+ }
+
+ const changeList: string[] = [];
+ list.forEach((value, index) => {
+ if (!value.change) {
+ return;
+ }
+ changeList.push(value.yang ? CHANGE_YANG[index] : CHANGE_YIN[index]);
+ });
+
+ const upIndex =
+ (list[5].yang ? 4 : 0) + (list[4].yang ? 2 : 0) + (list[3].yang ? 1 : 0);
+ const downIndex =
+ (list[2].yang ? 4 : 0) + (list[1].yang ? 2 : 0) + (list[0].yang ? 1 : 0);
+
+ const guaIndex = guaIndexData[upIndex][downIndex] - 1;
+ const guaName1 = guaListData[guaIndex];
+
+ let guaName2: string;
+ if (upIndex === downIndex) {
+ guaName2 = GUA_DICT1[upIndex] + "为" + GUA_DICT2[upIndex];
+ } else {
+ guaName2 = GUA_DICT2[upIndex] + GUA_DICT2[downIndex] + guaName1;
+ }
+
+ const guaDesc = GUA_DICT1[upIndex] + "上" + GUA_DICT1[downIndex] + "下";
+
+ return {
+ guaMark: `${(guaIndex + 1).toString().padStart(2, "0")}.${guaName2}`,
+ guaTitle: `周易第${guaIndex + 1}卦`,
+ guaResult: `${guaName1}卦(${guaName2})_${guaDesc}`,
+ guaChange:
+ changeList.length === 0 ? "无变爻" : `变爻: ${changeList.toString()}`,
+ };
+}
+
+export const YAO_LABELS = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
+
+export const COIN_OPTIONS = [
+ { count: 0, label: "老阴", desc: "0 正 · 变爻 ✕" },
+ { count: 1, label: "少阴", desc: "1 正" },
+ { count: 2, label: "少阳", desc: "2 正" },
+ { count: 3, label: "老阳", desc: "3 正 · 变爻 ○" },
+];
+
+export interface GuaResult {
+ list: HexagramObj[];
+ result: ResultObj;
+}
diff --git a/lib/calc/time.ts b/lib/calc/time.ts
new file mode 100644
index 0000000..348a7b1
--- /dev/null
+++ b/lib/calc/time.ts
@@ -0,0 +1,29 @@
+/** 中国标准时间基准经度(UTC+8) */
+export const CHINA_STANDARD_MERIDIAN = 120;
+
+/**
+ * 根据出生地经度校正真太阳时。
+ * 每差 1 度经度,时间差约 4 分钟。
+ */
+export function adjustToTrueSolarTime(
+ date: Date,
+ longitude: number,
+ standardMeridian = CHINA_STANDARD_MERIDIAN,
+): Date {
+ const offsetMinutes = (longitude - standardMeridian) * 4;
+ return new Date(date.getTime() + offsetMinutes * 60 * 1000);
+}
+
+export function formatDateTime(date: Date): string {
+ const pad = (n: number) => n.toString().padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+export function parseDateTime(
+ dateStr: string,
+ timeStr: string,
+): Date {
+ const [year, month, day] = dateStr.split("-").map(Number);
+ const [hour, minute] = timeStr.split(":").map(Number);
+ return new Date(year, month - 1, day, hour, minute, 0);
+}
diff --git a/lib/calc/timing.ts b/lib/calc/timing.ts
new file mode 100644
index 0000000..7573532
--- /dev/null
+++ b/lib/calc/timing.ts
@@ -0,0 +1,98 @@
+import { Solar } from "lunar-javascript";
+import {
+ adjustToTrueSolarTime,
+ formatDateTime,
+ parseDateTime,
+} from "@/lib/calc/time";
+
+export interface TimingInfo {
+ solarTime: string;
+ lunarDate: string;
+ yearGanZhi: string;
+ monthGanZhi: string;
+ dayGanZhi: string;
+ timeGanZhi: string;
+ prevJieQi: string;
+ nextJieQi: string;
+}
+
+export function getTimingInfo(dateTime: Date): TimingInfo {
+ const solar = Solar.fromYmdHms(
+ dateTime.getFullYear(),
+ dateTime.getMonth() + 1,
+ dateTime.getDate(),
+ dateTime.getHours(),
+ dateTime.getMinutes(),
+ 0,
+ );
+ const lunar = solar.getLunar();
+
+ return {
+ solarTime: formatDateTime(dateTime),
+ lunarDate: lunar.toString(),
+ yearGanZhi: lunar.getYearInGanZhi(),
+ monthGanZhi: lunar.getMonthInGanZhi(),
+ dayGanZhi: lunar.getDayInGanZhi(),
+ timeGanZhi: lunar.getTimeInGanZhi(),
+ prevJieQi: lunar.getPrevJieQi()?.getName() ?? "—",
+ nextJieQi: lunar.getNextJieQi()?.getName() ?? "—",
+ };
+}
+
+export function getTimingInfoFromStrings(
+ date: string,
+ time: string,
+): TimingInfo {
+ return getTimingInfo(parseDateTime(date, time));
+}
+
+export function getTimingInfoWithLongitude(
+ date: string,
+ time: string,
+ longitude: number,
+): { timing: TimingInfo; trueSolarTime: string } {
+ const local = parseDateTime(date, time);
+ const trueSolar = adjustToTrueSolarTime(local, longitude);
+ return {
+ timing: getTimingInfo(trueSolar),
+ trueSolarTime: formatDateTime(trueSolar),
+ };
+}
+
+export function formatLiuyaoTimingForPrompt(
+ timing: TimingInfo,
+ trueSolarTime: string,
+ locationName: string,
+ longitude: number,
+): string {
+ return [
+ "【起卦时空 · 天时】",
+ `起卦时刻:${timing.solarTime}`,
+ `真太阳时:${trueSolarTime}`,
+ `农历:${timing.lunarDate}`,
+ `年柱:${timing.yearGanZhi},月柱:${timing.monthGanZhi},日柱:${timing.dayGanZhi},时柱:${timing.timeGanZhi}`,
+ `节气:上一节气 ${timing.prevJieQi},下一节气 ${timing.nextJieQi}`,
+ "",
+ "【起卦地域 · 地利】",
+ `位置:${locationName}`,
+ `经度:${longitude}°`,
+ ].join("\n");
+}
+
+export function formatTimingForPrompt(
+ timing: TimingInfo,
+ locationName: string,
+ longitude: number,
+): string {
+ return [
+ "【天时】",
+ `测算时刻:${timing.solarTime}`,
+ `农历:${timing.lunarDate}`,
+ `年柱:${timing.yearGanZhi},月柱:${timing.monthGanZhi},日柱:${timing.dayGanZhi},时柱:${timing.timeGanZhi}`,
+ `节气:上一节气 ${timing.prevJieQi},下一节气 ${timing.nextJieQi}`,
+ "",
+ "【地利】",
+ `当前位置:${locationName}`,
+ `经度:${longitude}°`,
+ ].join("\n");
+}
diff --git a/lib/content/zhouyi.ts b/lib/content/zhouyi.ts
index e7251cf..dcceec7 100644
--- a/lib/content/zhouyi.ts
+++ b/lib/content/zhouyi.ts
@@ -1,13 +1,54 @@
import fs from "fs/promises";
import path from "path";
-const CONTENT_ROOT = path.join(process.cwd(), "content", "zhouyi", "docs");
+const DOCS_ROOT = path.join(process.cwd(), "content", "zhouyi", "docs");
+const OTHER_ROOT = path.join(DOCS_ROOT, "other");
+
+export type LearnVariant = "traditional" | "simplified";
+
+function getVariantRoot(variant: LearnVariant): string {
+ return variant === "traditional" ? DOCS_ROOT : OTHER_ROOT;
+}
+
+export async function listGuaMarks(
+ variant: LearnVariant = "traditional",
+): Promise {
+ const dir = getVariantRoot(variant);
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ return entries
+ .filter((entry) => entry.isDirectory() && /^\d{2}\./.test(entry.name))
+ .map((entry) => entry.name)
+ .sort();
+}
export async function readGuaMarkdown(guaMark: string): Promise {
- const filePath = path.join(CONTENT_ROOT, guaMark, "index.md");
+ const filePath = path.join(DOCS_ROOT, guaMark, "index.md");
return fs.readFile(filePath, "utf-8");
}
+export async function readLearnMarkdown(
+ guaMark: string,
+ variant: LearnVariant = "traditional",
+): Promise {
+ const root = getVariantRoot(variant);
+ const filePath =
+ guaMark === "index"
+ ? path.join(root, "index.md")
+ : path.join(root, guaMark, "index.md");
+ return fs.readFile(filePath, "utf-8");
+}
+
+export function stripFrontmatter(content: string): string {
+ if (!content.startsWith("---")) {
+ return content;
+ }
+ const end = content.indexOf("---", 3);
+ if (end === -1) {
+ return content;
+ }
+ return content.slice(end + 3).trimStart();
+}
+
export function extractZhangMingRen(guaDetail: string): string | undefined {
return guaDetail
.match(/(\*\*台灣張銘仁[\s\S]*?)(?=周易第\d+卦)/)?.[1]
@@ -39,3 +80,11 @@ export function extractChangeDetails(
return changeList;
}
+
+export function getGuaNumber(guaMark: string): number {
+ return parseInt(guaMark.split(".")[0], 10);
+}
+
+export function getGuaName(guaMark: string): string {
+ return guaMark.split(".").slice(1).join(".");
+}
diff --git a/lib/data/regions.json b/lib/data/regions.json
new file mode 100644
index 0000000..8504302
--- /dev/null
+++ b/lib/data/regions.json
@@ -0,0 +1,103 @@
+{
+ "110000": {
+ "name": "北京市",
+ "longitude": 116.4074,
+ "children": {
+ "110101": { "name": "东城区", "longitude": 116.4164 },
+ "110105": { "name": "朝阳区", "longitude": 116.4434 },
+ "110108": { "name": "海淀区", "longitude": 116.2983 }
+ }
+ },
+ "310000": {
+ "name": "上海市",
+ "longitude": 121.4737,
+ "children": {
+ "310101": { "name": "黄浦区", "longitude": 121.4903 },
+ "310115": { "name": "浦东新区", "longitude": 121.5447 },
+ "310104": { "name": "徐汇区", "longitude": 121.4365 }
+ }
+ },
+ "440000": {
+ "name": "广东省",
+ "longitude": 113.2665,
+ "children": {
+ "440100": { "name": "广州市", "longitude": 113.2644 },
+ "440300": { "name": "深圳市", "longitude": 114.0579 },
+ "440600": { "name": "佛山市", "longitude": 113.1214 }
+ }
+ },
+ "330000": {
+ "name": "浙江省",
+ "longitude": 120.1536,
+ "children": {
+ "330100": { "name": "杭州市", "longitude": 120.1551 },
+ "330200": { "name": "宁波市", "longitude": 121.5503 },
+ "330300": { "name": "温州市", "longitude": 120.6994 }
+ }
+ },
+ "320000": {
+ "name": "江苏省",
+ "longitude": 118.7969,
+ "children": {
+ "320100": { "name": "南京市", "longitude": 118.7969 },
+ "320500": { "name": "苏州市", "longitude": 120.5853 },
+ "320200": { "name": "无锡市", "longitude": 120.3119 }
+ }
+ },
+ "510000": {
+ "name": "四川省",
+ "longitude": 104.0665,
+ "children": {
+ "510100": { "name": "成都市", "longitude": 104.0665 },
+ "510700": { "name": "绵阳市", "longitude": 104.6796 }
+ }
+ },
+ "420000": {
+ "name": "湖北省",
+ "longitude": 114.3419,
+ "children": {
+ "420100": { "name": "武汉市", "longitude": 114.3055 },
+ "420500": { "name": "宜昌市", "longitude": 111.2865 }
+ }
+ },
+ "610000": {
+ "name": "陕西省",
+ "longitude": 108.9398,
+ "children": {
+ "610100": { "name": "西安市", "longitude": 108.9398 },
+ "610300": { "name": "宝鸡市", "longitude": 107.2376 }
+ }
+ },
+ "370000": {
+ "name": "山东省",
+ "longitude": 117.0009,
+ "children": {
+ "370100": { "name": "济南市", "longitude": 117.1205 },
+ "370200": { "name": "青岛市", "longitude": 120.3826 }
+ }
+ },
+ "430000": {
+ "name": "湖南省",
+ "longitude": 112.9834,
+ "children": {
+ "430100": { "name": "长沙市", "longitude": 112.9388 },
+ "430200": { "name": "株洲市", "longitude": 113.1340 }
+ }
+ },
+ "500000": {
+ "name": "重庆市",
+ "longitude": 106.5516,
+ "children": {
+ "500103": { "name": "渝中区", "longitude": 106.5629 },
+ "500112": { "name": "渝北区", "longitude": 106.6304 }
+ }
+ },
+ "350000": {
+ "name": "福建省",
+ "longitude": 119.2965,
+ "children": {
+ "350100": { "name": "福州市", "longitude": 119.2965 },
+ "350200": { "name": "厦门市", "longitude": 118.0894 }
+ }
+ }
+}
diff --git a/lib/data/regions.ts b/lib/data/regions.ts
new file mode 100644
index 0000000..9a47c2c
--- /dev/null
+++ b/lib/data/regions.ts
@@ -0,0 +1,50 @@
+import regionsData from "@/lib/data/regions.json";
+
+export interface RegionNode {
+ name: string;
+ longitude: number;
+ children?: Record;
+}
+
+export type RegionsData = Record;
+
+export const regions = regionsData as RegionsData;
+
+export function getProvinces(): { code: string; name: string }[] {
+ return Object.entries(regions).map(([code, node]) => ({
+ code,
+ name: node.name,
+ }));
+}
+
+export function getCities(provinceCode: string): { code: string; name: string }[] {
+ const province = regions[provinceCode];
+ if (!province?.children) {
+ return [];
+ }
+ return Object.entries(province.children).map(([code, node]) => ({
+ code,
+ name: node.name,
+ }));
+}
+
+export function getRegionLocation(
+ provinceCode: string,
+ cityCode: string,
+): { name: string; longitude: number } | null {
+ const province = regions[provinceCode];
+ if (!province) {
+ return null;
+ }
+ const city = province.children?.[cityCode];
+ if (city) {
+ return {
+ name: `${province.name}${city.name}`,
+ longitude: city.longitude,
+ };
+ }
+ return {
+ name: province.name,
+ longitude: province.longitude,
+ };
+}
diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts
new file mode 100644
index 0000000..4fe881b
--- /dev/null
+++ b/lib/prompts/index.ts
@@ -0,0 +1,24 @@
+export const LIUYAO_SYSTEM_PROMPT = `你是一位精通《周易》六爻的 AI 解读师,根据用户提供的卦象、起卦时空和问事,给出准确的卦象解读和实用建议。
+
+任务要求:逻辑清晰,语气得当
+1. 结合起卦时辰(节气、日柱、时辰)与地域,分析天时地利对卦象的影响
+2. 解读主卦、变爻及变卦,说明整体趋势和吉凶
+3. 针对用户问事,结合卦象给出具体分析和可行建议`;
+
+export const BAZI_SYSTEM_PROMPT = `你是一位精通子平八字的命理分析师,根据用户提供的排盘信息和问题,给出专业、清晰的命理解读。
+
+任务要求:
+1. 分析命局格局与五行喜忌
+2. 解读十神组合含义
+3. 结合大运流年分析趋势
+4. 提示相关神煞吉凶
+5. 针对用户具体问题给出切实可行的建议
+语气得当,逻辑清晰,避免绝对化断言。`;
+
+export const COMBINED_SYSTEM_PROMPT = `你是一位融合天时、地利、人和的综合命理顾问,精通子平八字与周易六爻。
+
+任务要求:
+1. 综合天时(节气、日柱时辰)、地利(地域经度方位)、人和(八字命局)进行分析
+2. 若用户提供卦象,结合卦辞与变爻补充解读
+3. 针对用户问事给出具体、可操作的指引
+语气得当,逻辑清晰,各维度分析要有机融合而非简单罗列。`;
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index e342906..235977f 100644
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
-# 知命阁(zhimingge)服务器更新脚本
+# 知命阁(zhimingge)Ubuntu 服务器更新脚本
# 用法:cd /opt/zhimingge && bash scripts/deploy.sh
set -euo pipefail
APP_DIR="/opt/zhimingge"
APP_NAME="zhimingge"
+APP_PORT="${PORT:-3130}"
cd "$APP_DIR"
@@ -22,8 +23,14 @@ echo "==> 确保日志目录存在..."
mkdir -p logs
echo "==> 重启 PM2..."
-pm2 restart "$APP_NAME" || pm2 start ecosystem.config.cjs
+if pm2 describe "$APP_NAME" > /dev/null 2>&1; then
+ pm2 restart "$APP_NAME"
+else
+ pm2 start ecosystem.config.cjs
+fi
-echo "==> 部署完成"
+pm2 save
+
+echo "==> 部署完成,验证 http://127.0.0.1:${APP_PORT} ..."
pm2 status "$APP_NAME"
-curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:3000 || true
+curl -s -o /dev/null -w "HTTP %{http_code}\n" "http://127.0.0.1:${APP_PORT}" || true
diff --git a/types/lunar-javascript.d.ts b/types/lunar-javascript.d.ts
new file mode 100644
index 0000000..99f31c1
--- /dev/null
+++ b/types/lunar-javascript.d.ts
@@ -0,0 +1,73 @@
+declare module "lunar-javascript" {
+ export class Solar {
+ static fromYmdHms(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ second: number,
+ ): Solar;
+ getLunar(): Lunar;
+ toYmd(): string;
+ toYmdHms(): string;
+ }
+
+ export class Lunar {
+ getEightChar(): EightChar;
+ getDayInGanZhi(): string;
+ getTimeInGanZhi(): string;
+ getYearInGanZhi(): string;
+ getMonthInGanZhi(): string;
+ getDayJiShen(): string[];
+ getDayXiongSha(): string[];
+ getPrevJieQi(): JieQi | null;
+ getNextJieQi(): JieQi | null;
+ toString(): string;
+ }
+
+ export class EightChar {
+ getYear(): string;
+ getMonth(): string;
+ getDay(): string;
+ getTime(): string;
+ getYearShiShenGan(): string;
+ getMonthShiShenGan(): string;
+ getDayShiShenGan(): string;
+ getTimeShiShenGan(): string;
+ getYearShiShenZhi(): string[];
+ getMonthShiShenZhi(): string[];
+ getDayShiShenZhi(): string[];
+ getTimeShiShenZhi(): string[];
+ getYearHideGan(): string[];
+ getMonthHideGan(): string[];
+ getDayHideGan(): string[];
+ getTimeHideGan(): string[];
+ getYearNaYin(): string;
+ getMonthNaYin(): string;
+ getDayNaYin(): string;
+ getTimeNaYin(): string;
+ getYun(gender: number, sect?: number): Yun;
+ }
+
+ export class Yun {
+ getStartYear(): number;
+ getStartMonth(): number;
+ getStartDay(): number;
+ getDaYun(): DaYun[];
+ }
+
+ export class DaYun {
+ getGanZhi(): string;
+ getLiuNian(): LiuNian[];
+ }
+
+ export class LiuNian {
+ getGanZhi(): string;
+ getYear(): number;
+ }
+
+ export class JieQi {
+ getName(): string;
+ }
+}