Author SHA1 Message Date
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c7880bbd8 fix(ci): GOPROXY 与 npm 镜像源修复两条流水线的依赖下载失败
Build and Publish Commercial Web / build (push) Failing after 3m18s
Build and Publish Server / build (push) Successful in 9m33s
server-deploy 在 Install dependencies 失败、web-deploy 在 Install pnpm
失败——亚洲 runner 直连 proxy.golang.org / registry.npmjs.org 不稳定。
Go 侧加 goproxy.cn 优先回落官方源;pnpm 安装与前端依赖安装走
registry.npmmirror.com。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 08:34:04 +00:00
gouki 69b8907711 docs: 新增根 CHANGELOG 记录部署拆分与演示版改造 2026-10-18 21:28:22 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 51d1a1999f feat: 小程序演示版改造 + 服务端安全加固
Build and Publish Commercial Web / build (push) Failing after 25s
Publish Mini Program Dev Version / publish (push) Successful in 1m9s
Build and Publish Server / build (push) Failing after 6m42s
- 许愿树移除付费许愿/商品选择/微信支付调用,全部走免费许愿
- 许愿发布前接入微信 msgSecCheck v2 内容安全检测(access_token 内存缓存)
- 许愿字数限制改为按字符计算(中文按 1 字计)
- 占卜页改名「民俗趣算」并弱化「算命」措辞,占卜/每日运势页加文化声明
- 生产环境 JWT_SECRET 缺失或为默认值/不足 32 位时拒绝启动

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-18 21:25:24 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 12fa2b0d19 refactor(server): 拆分 cmd/mini 与 cmd/web 两个部署入口
Build and Publish Commercial Web / build (push) Failing after 1m25s
Build and Publish Server / build (push) Successful in 3m25s
小程序接口(lunar.neatcn.com)与商业版 Web(kepo.net)此前共用一个
二进制,导致小程序域名暴露商业后台与 Stripe Webhook,商业站点也
暴露了许愿树等小程序接口。现拆为两个入口:

- cmd/mini:微信登录、生辰档案、许愿树、百科、版本号
- cmd/web:邮箱登录、commerce、报告/订单、/admin 后台、商业前端
  静态托管与 SPA 回退
- internal/app 承载共用启动与路由注册;图片资源移至 internal/assets

CI:server-deploy.yml 改构建 cmd/mini 并随包移除后台静态资源;
新增 web-deploy.yml 构建 cmd/web + web-commercial 前端,发布
web-v* Release;deploy-watch.sh 支持 TAG_PREFIX/ASSET_NAME 变量;
新增 deploy-web.sh(systemd 版)与 kepo 一次性配置文档
.gitea/docs/PULL_DEPLOY_WEB.md。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-18 11:31:51 +00:00
gouki 28309e0257 test(mini): 为核心工具函数添加 node:test 单元测试
Publish Mini Program Dev Version / publish (push) Successful in 2m56s
- 新增 mini/utils/core/__tests__/index.test.js,70 个测试用例
- 覆盖:公农历互转、干支计算、生肖星座、纳音、节气、宜忌、冲煞、
  地支关系、十神、天干合冲、五行分析、称骨算命、大运流年分析等
- 未导出函数通过 getDayInfo/getAlmanacInfo 间接验证
- 更新 package.json test 脚本,npm test 同时运行单元测试和 CI 绑定检查
- 使用 Node.js 内置 node:test,零额外依赖
2026-10-14 12:50:06 +00:00
gouki 73374bf93e docs(AGENTS.md): 补充 web/packages/core 10 个 TypeScript 测试文件到测试覆盖现状
Build and Publish Server / build (push) Successful in 3m46s
2026-10-14 12:46:30 +00:00
gouki a07ddb46e2 merge: 纳入 @lunar/core 排盘核心与排盘保存显式同意
# Conflicts:
#	REMINDER.md
#	web-commercial/CHANGELOG.md
#	web-commercial/Fetures.md
2026-10-12 22:24:40 +00:00
gouki 0e21d8e63f fix(mini): 修复设置页绑定漂移并新增 WXML-JS 一致性检查
Publish Mini Program Dev Version / publish (push) Successful in 1m5s
settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
2026-09-30 00:48:50 +00:00
gouki 59d1af73b9 docs(agents): 更新测试覆盖现状为 6 个已跟踪测试文件,含新增邮箱验证码测试 2026-09-29 23:09:15 +00:00
gouki b63aabaa79 fix(server): 邮件验证码发送失败分支记录 provider 错误与上游状态码
Build and Publish Server / build (push) Successful in 4m11s
handler 失败分支不再丢弃 service 错误:记录含服务商名称与上游状态码的 err 供生产排障关联,对外响应保持通用 503 不变。
2026-09-29 23:03:46 +00:00
gouki 9b0e290d5d chore(git): 根 .gitignore 忽略 worktrees/ 工作树目录,避免污染变更集 2026-09-29 23:00:52 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 132fef051b feat(commerce): 排盘保存需显式同意,默认本地计算不上传
- 前端免费排盘默认不保存,勾选同意后才调用保存接口
- 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口
- 付费结算单独披露报告快照与订单信息将由服务端存储
- 管理端展示同意版本与时间,历史无同意证据记录保留并标注

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:21:16 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ea6828b925 feat(web): 纳入 @lunar/core 排盘核心并批准 esbuild 构建脚本
web-commercial 依赖 file:../web/packages/core,此前 web/ 被 gitignore,
干净 worktree 无法构建商业前端;同时将 esbuild 加入 allowBuilds。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:21:08 +00:00
130 changed files with 15219 additions and 441 deletions
+137
View File
@@ -0,0 +1,137 @@
# kepo.net 商业版 Web 拉取式部署配置(一次性)
> 安全原则同 [PULL_DEPLOY.md](./PULL_DEPLOY.md):CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由 kepo 服务器本地定时任务轮询 Gitea Release 自动完成。
> 本文档所有操作均由管理员本人在服务器终端执行,不依赖 AI 远程执行。
## 架构
```
push main → Gitea Actions 构建 cmd/web + web-commercial 前端 → web-deploy.tar.gz 发布为 web-v* Release
↓
kepo 服务器 cron(每分钟)运行 deploy-watch.sh(TAG_PREFIX=web-v)→ 发现新 release → HTTPS 拉取
↓
deploy-web.sh 原子切换 + systemctl restart lunar-web + 健康检查(失败自动回滚)
↓
Cloudflare (HTTPS 终结) → kepo 源站 :8080 lunar-web systemd 服务
```
## 与小程序接口部署的差异
| | lunar.neatcn.com | kepo.net |
|---|---|---|
| 二进制 | `cmd/mini` | `cmd/web` |
| Release 前缀 | `server-v*` | `web-v*` |
| 部署包 | `deploy.tar.gz` | `web-deploy.tar.gz` |
| 运行方式 | Docker 容器 | systemd 服务 `lunar-web` |
| 部署目录 | `/opt/lunar/production` | `/opt/lunar-web/production` |
## 一次性安装步骤(按序执行)
### 1. 准备目录与脚本
```bash
mkdir -p /opt/lunar-web/{production,scripts,backups}
# 从仓库复制脚本(任选其一):
# git clone 后 cp .gitea/scripts/{deploy-watch.sh,deploy-web.sh} /opt/lunar-web/scripts/
# 或直接下载原始文件写入
chmod +x /opt/lunar-web/scripts/*.sh
```
### 2. 生产环境变量
写入 `/opt/lunar-web/production/.env`,权限 `600`:
```ini
SERVER_ENV=production
SERVER_PORT=8080
PUBLIC_BASE_URL=https://kepo.net
WEB_APP_URL=https://kepo.net
CORS_ORIGINS=https://kepo.net,https://www.kepo.net
DB_TYPE=mysql
DB_HOST=<数据库地址>
DB_PORT=3306
DB_USER=<用户名>
DB_PASSWORD=<密码>
DB_NAME=lunar_web # 建议与小程序库分开;若暂时共用,填 lunar
JWT_SECRET=<强随机值,至少32位>
ADMIN_PASSWORD=<后台登录密码>
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USERNAME=<QQ邮箱>
SMTP_PASSWORD=<SMTP授权码>
SMTP_FROM_EMAIL=z@kepo.net
SMTP_TLS_MODE=implicit
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
BINANCE_PAY_API_KEY=
BINANCE_PAY_SECRET_KEY=
BINANCE_PAY_WEBHOOK_PUBLIC_KEY=
```
> kepo 侧不需要 `MINI_APP_ID` / `MINI_APP_SECRET` / `WECHAT_PAY_*`;
> lunar 侧则不需要 SMTP / Stripe / Binance 变量。
### 3. systemd 服务
`/etc/systemd/system/lunar-web.service`:
```ini
[Unit]
Description=Lunar Commercial Web (kepo.net)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/lunar-web/production/current
EnvironmentFile=/opt/lunar-web/production/.env
ExecStart=/opt/lunar-web/production/current/bin/server
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable lunar-web
# 首次启动前需要先手工放一版部署包,或等待第一次 Release 拉取
```
### 4. 定时拉取任务
crontab(`crontab -e`)添加:
```cron
* * * * * TAG_PREFIX=web-v ASSET_NAME=web-deploy.tar.gz \
STATE_FILE=/opt/lunar-web/production/.last-deployed-release \
DEPLOY_SCRIPT=/opt/lunar-web/scripts/deploy-web.sh \
LOG_FILE=/opt/lunar-web/deploy-watch.log \
/opt/lunar-web/scripts/deploy-watch.sh
```
首次运行后会自动部署最新 `web-v*` Release。日志:`/opt/lunar-web/deploy-watch.log`。
### 5. Cloudflare 回源
kepo.net 已走 Cloudflare。确认回源指向本机 8080(或 Nginx 反代到 8080)。
服务只监听 `127.0.0.1` 或由防火墙限制 8080 仅本机访问。
## 验证
```bash
curl -s http://127.0.0.1:8080/api/version # 应返回 version/buildTime/commitSha
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/admin/
```
## 注意
- **数据库**:短期内两边共用 `lunar` 库(后台 `/admin` 在 kepo 侧统一查看订单与小程序数据)。后续如需分离,新建 `lunar_web` 库并改 `DB_NAME`。
- **回滚**:`deploy-web.sh` 健康检查失败会自动恢复 `current.old`;也可手动 `systemctl restart lunar-web`。
- **小程序端**:部署包结构不变(`deploy/bin/server`),doc79 上的 watcher 无需改动。
+12 -5
View File
@@ -13,6 +13,11 @@ set -u
GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}"
REPO="${REPO:-gouki/lunar-mini}"
# 同一脚本服务两种部署形态:
# 小程序接口(doc79):默认 server-v* + deploy.tar.gz
# 商业版 Web(kepo):TAG_PREFIX=web-v ASSET_NAME=web-deploy.tar.gz
TAG_PREFIX="${TAG_PREFIX:-server-v}"
ASSET_NAME="${ASSET_NAME:-deploy.tar.gz}"
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-release}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
@@ -28,7 +33,7 @@ if [ -n "${GITEA_DEPLOY_TOKEN:-}" ]; then
AUTH_ARGS=(-H "Authorization: token ${GITEA_DEPLOY_TOKEN}")
fi
# 查询最新 20 个 release,取 tag 前缀为 server-v 且带 deploy.tar.gz 附件的最新一个
# 查询最新 20 个 release,取 tag 前缀匹配且带部署包附件的最新一个
RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
"${AUTH_ARGS[@]}" \
"$GITEA_URL/api/v1/repos/$REPO/releases?limit=20" \
@@ -37,16 +42,18 @@ RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
exit 1
}
PARSED=$(python3 - "$WORK_DIR/releases.json" <<'PY'
import json, sys
PARSED=$(TAG_PREFIX="$TAG_PREFIX" ASSET_NAME="$ASSET_NAME" python3 - "$WORK_DIR/releases.json" <<'PY'
import json, os, sys
tag_prefix = os.environ["TAG_PREFIX"]
asset_name = os.environ["ASSET_NAME"]
releases = json.load(open(sys.argv[1]))
for r in releases:
if not r.get("tag_name", "").startswith("server-v"):
if not r.get("tag_name", "").startswith(tag_prefix):
continue
if r.get("draft") or r.get("prerelease"):
continue
for a in r.get("assets", []):
if a.get("name") == "deploy.tar.gz":
if a.get("name") == asset_name:
print(r["id"])
print(r["tag_name"])
print(a["id"])
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# 商业版 Web(kepo.net)自动部署脚本 - systemd 直接运行二进制
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
# 使用方法: ./deploy-web.sh <web-deploy.tar.gz>
#
# 目录约定:
# /opt/lunar-web/production/current 当前运行版本(systemd WorkingDirectory)
# /opt/lunar-web/production/.env 环境变量(systemd EnvironmentFile)
# /opt/lunar-web/backups 历史版本备份
#
# 可通过环境变量覆盖:SERVICE_NAME / DEPLOY_DIR / BACKUP_DIR / HEALTH_URL
set -e
DEPLOY_PACKAGE="$1"
SERVICE_NAME="${SERVICE_NAME:-lunar-web}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar-web/production}"
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar-web/backups}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/version}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 回滚:恢复 current.old 并重启服务
rollback() {
if [ -d "$DEPLOY_DIR/current.old" ]; then
rm -rf "$DEPLOY_DIR/current"
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
systemctl restart "$SERVICE_NAME" || log_error "回滚后服务重启失败,需人工介入"
log_warn "已回滚到上一版本"
else
log_error "无可回滚版本,需人工介入"
fi
}
if [ ! -f "$DEPLOY_PACKAGE" ]; then
log_error "部署包不存在: $DEPLOY_PACKAGE"
exit 1
fi
log_info "开始自动部署(商业版 Web)..."
log_info "部署包: $DEPLOY_PACKAGE"
log_info "systemd 服务: $SERVICE_NAME"
log_info "部署目录: $DEPLOY_DIR"
mkdir -p "$DEPLOY_DIR" "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/backup_${TIMESTAMP}.tar.gz"
if [ -d "$DEPLOY_DIR/current" ]; then
log_info "备份当前版本到: $BACKUP_FILE"
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
fi
# 解压到暂存目录(不碰 current,避免部署空窗)
log_info "解压部署包..."
TEMP_DIR=$(mktemp -d)
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
if [ ! -d "$TEMP_DIR/deploy" ]; then
log_error "部署包结构错误:缺少 deploy 目录"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
log_error "部署包缺少 bin/server 二进制"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/frontend/index.html" ]; then
log_error "部署包缺少 frontend/index.html(商业前端未构建?)"
rm -rf "$TEMP_DIR"
exit 1
fi
chmod +x "$TEMP_DIR/deploy/bin/server"
# 目录级交换,接近原子
log_info "部署新版本..."
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
rm -rf "$DEPLOY_DIR/current.old"
if [ -d "$DEPLOY_DIR/current" ]; then
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
fi
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
rm -rf "$TEMP_DIR"
# 重启 systemd 服务
log_info "重启服务: $SERVICE_NAME"
if systemctl restart "$SERVICE_NAME"; then
log_info "服务重启成功"
else
log_error "服务重启失败,回滚到上一版本"
rollback
exit 1
fi
# 健康检查
log_info "执行健康检查: $HEALTH_URL"
HEALTHY=0
for i in $(seq 1 10); do
sleep 2
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" = "1" ]; then
log_info "健康检查通过"
curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
rm -rf "$DEPLOY_DIR/current.old"
else
log_error "健康检查失败,回滚到上一版本"
journalctl -u "$SERVICE_NAME" --no-pager -n 50 || true
rollback
exit 1
fi
# 清理旧备份(保留最近 5 个)
ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -f
log_info "部署完成!"
log_info "备份文件: $BACKUP_FILE"
log_info "部署目录: $DEPLOY_DIR/current"
+7 -5
View File
@@ -60,6 +60,8 @@ jobs:
working-directory: server
run: |
export HOME="$RUNNER_WORKSPACE"
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
"$LUNAR_GO" mod download
- name: Run tests
@@ -85,11 +87,13 @@ jobs:
echo "Building server version: $VERSION"
# ldflags 注入版本信息;目标平台为 doc79(linux/amd64)
# 本流水线构建的是小程序接口二进制(cmd/mini);
# 商业版 Web(cmd/web)由 web-deploy.yml 单独构建发布
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
-X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \
-X main.CommitSha=$COMMIT_SHORT" \
-o bin/server cmd/main.go
-o bin/server ./cmd/mini
# 版本元数据随包分发,便于服务器侧追溯
cat > bin/version.env <<EOF
@@ -107,10 +111,8 @@ jobs:
cp -r bin/ deploy/
cp -r migrations/ deploy/
cp -r scripts/ deploy/
# 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
if [ -d "web" ]; then
cp -r web/ deploy/web/
fi
# 管理后台已迁移到商业版 Web 服务(cmd/web),
# 小程序接口二进制不再注册 /admin,无需随包发布 web/ 静态资源
tar -czf deploy.tar.gz deploy/
sha256sum deploy.tar.gz
+278
View File
@@ -0,0 +1,278 @@
name: Build and Publish Commercial Web
on:
push:
branches:
- main
paths:
- "server/**"
- "web/**"
- "web-commercial/**"
- ".gitea/workflows/web-deploy.yml"
jobs:
build:
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
runs-on: ubuntu-latest
steps:
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
- name: Checkout
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}
REF_NAME: ${{ gitea.ref_name }}
run: |
git init -q .
git remote add origin "$REPO_URL"
git fetch -q --depth 20 origin "$REF_NAME"
git checkout -q FETCH_HEAD
git log --oneline -3
- name: Ensure curl available
run: |
if ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl
elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq curl
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl
else echo "无法安装 curl"; exit 1; fi
fi
curl --version | head -1
- name: Prepare Go toolchain
run: |
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
mkdir -p "$TOOLDIR"
if [ -x "$TOOLDIR/go/bin/go" ]; then
echo "复用缓存 Go 工具链"
else
wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
"https://go.dev/dl/go1.22.10.linux-amd64.tar.gz" \
|| wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
"https://mirrors.aliyun.com/golang/go1.22.10.linux-amd64.tar.gz"
rm -rf "$TOOLDIR/go"
tar -C "$TOOLDIR" -xzf "$TOOLDIR/go.tgz"
rm -f "$TOOLDIR/go.tgz"
fi
"$TOOLDIR/go/bin/go" version
echo "LUNAR_GO=$TOOLDIR/go/bin/go" >> "$GITEA_ENV"
- name: Prepare Node toolchain
run: |
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
mkdir -p "$TOOLDIR"
if [ -x "$TOOLDIR/node/bin/node" ] && "$TOOLDIR/node/bin/node" --version >/dev/null 2>&1; then
echo "复用缓存 Node 工具链"
else
rm -rf "$TOOLDIR/node" "$TOOLDIR/node.tgz"
mkdir -p "$TOOLDIR"
if ldd --version 2>&1 | grep -qi musl || [ ! -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
NODE_DIST="node-v22.12.0-linux-x64-musl"
NODE_SHA="8d96de0293fc3978d09e2a3fee68c66bca4efcca90f52105e6f7463636e97308"
URLS="https://registry.npmmirror.com/-/binary/node-unofficial-builds/v22.12.0/$NODE_DIST.tar.gz
https://unofficial-builds.nodejs.org/download/release/v22.12.0/$NODE_DIST.tar.gz"
else
NODE_DIST="node-v22.12.0-linux-x64"
NODE_SHA="e05a4d65232ae2b27b3d77da2e368522fb46b923335b8e0d5f77624c32484044"
URLS="https://nodejs.org/dist/v22.12.0/$NODE_DIST.tar.gz
https://mirrors.aliyun.com/nodejs-release/v22.12.0/$NODE_DIST.tar.gz"
fi
DL_OK=""
for url in $URLS; do
echo "尝试下载: $url"
if wget -t 2 -T 120 -q -O "$TOOLDIR/node.tgz" "$url"; then
if [ "$(sha256sum "$TOOLDIR/node.tgz" | cut -d' ' -f1)" = "$NODE_SHA" ]; then
echo "下载成功且校验通过"
DL_OK=1
break
fi
echo "sha256 校验失败,尝试下一源"
else
echo "wget 下载失败,尝试下一源"
fi
rm -f "$TOOLDIR/node.tgz"
done
test -n "$DL_OK" || { echo "::error::所有下载源均失败或校验不通过"; exit 1; }
tar -C "$TOOLDIR" -xzf "$TOOLDIR/node.tgz"
mv "$TOOLDIR/$NODE_DIST" "$TOOLDIR/node"
rm -f "$TOOLDIR/node.tgz"
if [ "$NODE_DIST" = "node-v22.12.0-linux-x64-musl" ] && [ ! -e /usr/lib/libstdc++.so.6 ] && [ ! -e /lib/libstdc++.so.6 ]; then
if command -v apk >/dev/null 2>&1; then apk add --no-cache libstdc++ libgcc || echo "::warning::libstdc++/libgcc 安装失败"; fi
fi
fi
if ! "$TOOLDIR/node/bin/node" --version; then
echo "::error::Node 二进制无法执行"
rm -rf "$TOOLDIR/node"
exit 1
fi
echo "PATH=$TOOLDIR/node/bin:$PATH" >> "$GITEA_ENV"
- name: Install pnpm
run: |
export HOME="$RUNNER_WORKSPACE"
# pnpm 随工具链缓存,避免每次重复下载
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
if [ ! -x "$TOOLDIR/pnpm/bin/pnpm" ]; then
rm -rf "$TOOLDIR/pnpm"
# 亚洲 runner 优先走 npmmirror,失败回落官方源
npm install -g --prefix "$TOOLDIR/pnpm" --registry=https://registry.npmmirror.com pnpm@9 \
|| npm install -g --prefix "$TOOLDIR/pnpm" --registry=https://registry.npmjs.org pnpm@9
fi
echo "PATH=$TOOLDIR/pnpm/bin:$PATH" >> "$GITEA_ENV"
"$TOOLDIR/pnpm/bin/pnpm" --version
- name: Run Go tests
working-directory: server
run: |
export HOME="$RUNNER_WORKSPACE"
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
"$LUNAR_GO" mod download
"$LUNAR_GO" test ./...
- name: Build commercial frontend
env:
CI: "true"
run: |
export HOME="$RUNNER_WORKSPACE"
# 用户级 npmrc:pnpm install 依赖下载走镜像源(lockfile 校验不受影响)
npm config set registry https://registry.npmmirror.com || true
# web/ 工作区:装依赖并构建 @lunar/core(web-commercial 的 file: 依赖)
cd web
pnpm install --frozen-lockfile
pnpm --filter @lunar/core build
cd ../web-commercial
pnpm install --frozen-lockfile
npx tsc -b
npx vite build
test -f dist/index.html
- name: Build web server binary
working-directory: server
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
run: |
export HOME="$RUNNER_WORKSPACE"
mkdir -p bin
VERSION="1.1.${GITEA_RUN_NUMBER}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
COMMIT_SHORT="${GITEA_SHA:0:7}"
echo "Building web server version: $VERSION"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
-X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \
-X main.CommitSha=$COMMIT_SHORT" \
-o bin/server ./cmd/web
cat > bin/version.env <<EOF
APP_VERSION=$VERSION
APP_BUILD_TIME=$BUILD_TIME
APP_COMMIT_SHA=$COMMIT_SHORT
EOF
cat bin/version.env
- name: Create deployment package
working-directory: server
run: |
mkdir -p deploy
cp -r bin/ deploy/
cp -r migrations/ deploy/
cp -r scripts/ deploy/
# 商业前端产物:web 二进制以相对路径 ./frontend 提供
cp -r ../web-commercial/dist deploy/frontend/
# 管理后台静态资源与模板(服务以相对路径 ./web 提供)
cp -r web/ deploy/web/
tar -czf web-deploy.tar.gz deploy/
sha256sum web-deploy.tar.gz
- name: Publish release with deployment package
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
run: |
set -e
VERSION="1.1.${GITEA_RUN_NUMBER}"
TAG="web-v${VERSION}"
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
test -n "$TOKEN"
AUTH="Authorization: token $TOKEN"
EXISTING=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$EXISTING" ]; then
curl -sS -X DELETE -H "$AUTH" "$API/releases/$EXISTING" -o /dev/null -w "删除旧 release: HTTP %{http_code}\n"
fi
CREATE_RESP=$(curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"web $VERSION\",\"target_commitish\":\"main\",\"body\":\"CI 自动构建,commit ${GITEA_SHA:0:7}。kepo 服务器由 deploy-watch.sh(TAG_PREFIX=web-v)拉取本附件完成部署。\"}" \
"$API/releases")
RELEASE_ID=$(printf '%s' "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$RELEASE_ID" ]; then
echo "创建 release 失败,API 响应: $CREATE_RESP"
exit 1
fi
echo "Release created: id=$RELEASE_ID tag=$TAG"
curl -sS -X POST -H "$AUTH" \
-F "attachment=@server/web-deploy.tar.gz" \
"$API/releases/$RELEASE_ID/assets" \
-o /dev/null -w "附件上传: HTTP %{http_code}\n"
echo "发布地址: https://gitea.neatcn.com/gouki/lunar-mini/releases/tag/$TAG"
- name: Cleanup old web releases (keep latest 10)
env:
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
run: |
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
AUTH="Authorization: token $TOKEN"
KEEP=10
PAIRS=$(curl -sS -H "$AUTH" "$API/releases?limit=50" \
| grep -oE '"id":[0-9]+,"tag_name":"web-v[^"]*"' || true)
TOTAL=$(printf '%s\n' "$PAIRS" | grep -c '"id"' || true)
if [ "$TOTAL" -le "$KEEP" ]; then
echo "当前 $TOTAL 个发布版本,未超过 $KEEP,无需清理"
exit 0
fi
printf '%s\n' "$PAIRS" | tail -n +$((KEEP + 1)) | while IFS= read -r line; do
[ -z "$line" ] && continue
RID=$(printf '%s' "$line" | grep -oE '"id":[0-9]+' | cut -d: -f2)
RTAG=$(printf '%s' "$line" | sed 's/.*"tag_name":"\([^"]*\)".*/\1/')
curl -sS -X DELETE -H "$AUTH" "$API/releases/$RID" -o /dev/null -w "删除旧发布 $RTAG: HTTP %{http_code}\n"
curl -sS -X DELETE -H "$AUTH" "$API/tags/$RTAG" -o /dev/null -w "删除 tag $RTAG: HTTP %{http_code}\n"
done
- name: Send success notification
if: success()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
run: |
VERSION="1.1.${GITEA_RUN_NUMBER}"
bash .gitea/scripts/notify.sh success \
"商业版 Web 构建发布成功" \
"版本: v${VERSION}\n产物: releases/tag/web-v${VERSION}\nkepo 服务器将自动拉取部署" || echo "通知发送失败,不影响主流程"
- name: Send failure notification
if: failure()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
bash .gitea/scripts/notify.sh failure \
"商业版 Web 构建失败" \
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
- name: Cleanup
if: always()
run: |
rm -rf server/deploy/
rm -f server/web-deploy.tar.gz
+5 -2
View File
@@ -64,10 +64,13 @@ server/public/hot
server/web/node_modules/
server/web/dist/
# web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web)
/web/
# 许愿树独立页面(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web)
# 注意:web/ 是 @lunar/core 排盘核心,web-commercial 构建依赖它,必须入库
/wishing-tree-pages/
# Git worktrees(本地并行开发目录,不属于本仓库源码;锚定根目录,避免误伤同名子目录)
/worktrees/
# 数据库
*.db
*.sqlite
+5 -1
View File
@@ -13,6 +13,9 @@ lunar 是一个融合中国传统文化(农历、八字、许愿树)的现
| 路径 | 说明 | 关键文件 |
|------|------|----------|
| `server/cmd/mini/` | 小程序接口入口(lunar.neatcn.com) | `main.go` |
| `server/cmd/web/` | 商业版 Web 入口(kepo.net,含后台与前端静态托管) | `main.go` |
| `server/internal/app/` | 两个入口的路由注册与共用启动逻辑 | `app.go`, `mini.go`, `web.go` |
| `server/internal/handler/` | HTTP API 处理器 | `user.go`, `commerce.go`, `pay.go`, `wiki.go` |
| `server/internal/service/` | 业务逻辑层 | `user.go`, `commerce.go`, `payment_provider.go`, `wish.go` |
| `server/internal/model/` | 数据模型 | `user.go`, `order.go`, `commerce.go`, `wish.go` |
@@ -68,7 +71,8 @@ pnpm build # 构建
## 测试覆盖现状
- Go 后端:2 个测试文件(`server/internal/service/` 下)
- Go 后端:6 个已跟踪测试文件(`server/internal/service/` 3 个:baike_term_sync_test.go、payment_provider_test.go、wiki_sync_test.go;`server/internal/handler/` 2 个:commerce_test.go、本次新增的邮箱验证码测试 email_auth_test.go;`server/internal/middleware/` 1 个:middleware_test.go)
- web 前端核心(`web/packages/core/`):10 个 TypeScript 测试文件(`src/__tests__/` 下:bazi、boneWeight、day、elementStrength、fortune、fortuneLuck、plumBlossom、relationship、shensha、yearMonths)
- 小程序前端:0 个测试文件
- 商业前端:0 个测试文件
+33
View File
@@ -0,0 +1,33 @@
# 变更日志
## [1.1.88+] 2026-10-18 — 部署形态拆分与小程序演示版
### 后端拆分(双入口)
- 后端拆为两个部署入口:`cmd/mini`(小程序接口,lunar.neatcn.com)与 `cmd/web`(商业版,kepo.net)。
- `cmd/mini` 仅保留微信登录、生辰档案、许愿树、百科;不再暴露邮箱登录、commerce、`/admin` 后台与支付 Webhook。
- `cmd/web` 仅保留邮箱验证码登录、commerce、订单/报告权益、`/admin` 后台、商业前端静态托管与 SPA 回退;不再暴露微信登录与许愿树接口。
- 共用代码收敛到 `internal/app`(启动/路由注册)与 `internal/assets`(内嵌图片)。
### CI/CD
- `server-deploy.yml` 改构建 `cmd/mini`,部署包不再携带后台静态资源。
- 新增 `web-deploy.yml`:构建 `cmd/web` + web-commercial 前端,发布 `web-v*` Release。
- `deploy-watch.sh` 支持 `TAG_PREFIX`/`ASSET_NAME` 环境变量;新增 `deploy-web.sh`(systemd 版,含健康检查与自动回滚)。
- 新增 kepo 一次性配置文档:`.gitea/docs/PULL_DEPLOY_WEB.md`。
### 安全加固
- 生产环境 `JWT_SECRET` 未设置、为默认值或短于 32 位时拒绝启动。
- 许愿发布前接入微信 `msgSecCheck` v2 内容安全检测(access_token 内存缓存)。
- 修复许愿字数按字节计算的问题(中文原只能写约 6 字),改为按字符计算。
### 小程序(演示版)
- 许愿树移除付费许愿、商品选择与微信支付调用,全部走免费许愿。
- 「占卜」页更名「民俗趣算」,「称骨算命」改为「称骨测算」;占卜页与每日运势页新增文化声明,明确"传统文化知识、仅供娱乐参考"。
### 分支整理
- SMTP2GO 未提交改动备份至 `smtp2go-backup` 分支,`main` 保留已验证可用的 QQ SMTP。
- 合并 `privacy-chart-consent`:`web/` 排盘核心纳入仓库(修复 web-commercial 干净构建),排盘保存需用户显式同意并限流。
+1
View File
@@ -14,6 +14,7 @@
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
- 排盘数据会进入服务端时,必须明确说明采集字段、保存目的与范围;免费排盘默认不上传,主动同意后才保存,同意版本需留档,公开写入接口必须限流。付费下单的报告快照应在下单前另行说明。
## 2026-09-25:邮件服务方案必须先确认代码可用
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* WXML-JS 绑定一致性静态检查(npm test / CI 门禁)
*
* 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用:
* wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo,
* 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。
* 本脚本把这类漂移拦在提交与 CI 阶段。
*
* 检查范围:pages/<name>/<name>.wxml 与同目录同名 .js 的配对
* 检查规则:
* 1. bind/catch 事件绑定(含 bind:tap 冒号写法)的方法名必须存在于对应 JS
* 2. {{}} 表达式中的根标识符(点号前的名字)必须存在于对应 JS(数据字段或方法)
* 3. wx:for 默认变量 item/index、wx:for-item/wx:for-index 别名、
* 字符串字面量与 true/false/null/undefined 自动忽略
* 4. LEGACY_DRIFT 中登记的历史遗留条目仅告警不失败(修复后应删除对应条目)
*
* 存在漂移时以非零退出码结束,使 npm test 与 CI 直接失败。
*/
const fs = require("node:fs");
const path = require("node:path");
const projectPath = process.cwd();
const pagesPath = path.join(projectPath, "pages");
const LITERAL_KEYWORDS = new Set(["true", "false", "null", "undefined"]);
// wx:for 的默认循环变量属于模板结构名而非页面定义,始终忽略
const DEFAULT_LOOP_VARS = new Set(["item", "index"]);
// 引入本检查前就存在的历史遗留漂移(本轮修复范围外,另行跟进)。
// 命中时仅告警不失败;对应页面修复后应删除这里的条目,让检查恢复全量严格。
const LEGACY_DRIFT = new Set([
'pages/calendar/calendar.wxml catchtap="noop"',
'pages/wish-detail/wish-detail.wxml bindtap="onShare"',
]);
function listPageNames() {
return fs
.readdirSync(pagesPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => {
const dir = path.join(pagesPath, name);
return (
fs.existsSync(path.join(dir, `${name}.wxml`)) &&
fs.existsSync(path.join(dir, `${name}.js`))
);
})
.sort();
}
function lineOf(text, index) {
let line = 1;
for (let i = 0; i < index; i++) {
if (text.charCodeAt(i) === 10) {
line++;
}
}
return line;
}
/** 提取 wxml 中的事件绑定与 {{}} 表达式(去掉注释,保留行号) */
function collectWxmlRefs(source) {
const text = source.replace(/<!--[\s\S]*?-->/g, "");
const loopVars = new Set();
const handlers = [];
const roots = [];
for (const match of text.matchAll(/wx:for-(?:item|index)="([A-Za-z_$][\w$]*)"/g)) {
loopVars.add(match[1]);
}
for (const match of text.matchAll(/((?:bind|catch):?[a-zA-Z]+)="([^"{}]+)"/g)) {
const name = match[2].trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
handlers.push({ ref: `${match[1]}="${name}"`, name, line: lineOf(text, match.index) });
}
}
for (const match of text.matchAll(/\{\{([^{}]*)\}\}/g)) {
for (const name of expressionRoots(match[1])) {
roots.push({ ref: `{{${name}}}`, name, line: lineOf(text, match.index) });
}
}
return { loopVars, handlers, roots };
}
/** 取 mustache 表达式的根标识符:字符串字面量、点号后的属性名、数字与关键字都不算 */
function expressionRoots(expression) {
const stripped = expression
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
const roots = new Set();
for (const match of stripped.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) {
if (!LITERAL_KEYWORDS.has(match[1])) {
roots.add(match[1]);
}
}
return roots;
}
/** 将 JS 的字符串与注释内容置为空白(保留换行),避免干扰键名提取与跨行匹配 */
function stripJsNoise(source) {
let result = "";
let index = 0;
while (index < source.length) {
const ch = source[index];
const next = source[index + 1];
if (ch === "/" && next === "/") {
while (index < source.length && source[index] !== "\n") {
result += " ";
index++;
}
continue;
}
if (ch === "/" && next === "*") {
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
result += source[index] === "\n" ? "\n" : " ";
index++;
}
result += " ";
index += 2;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
result += " ";
index++;
while (index < source.length) {
const current = source[index];
if (current === "\\") {
result += source[index + 1] === "\n" ? "\n" : " ";
index += 2;
continue;
}
if (current === ch) {
result += " ";
index++;
break;
}
result += current === "\n" ? "\n" : " ";
index++;
}
continue;
}
result += ch;
index++;
}
return result;
}
/** 提取页面 JS 中 Page({}) 的数据字段、setData 键与方法名 */
function collectJsNames(source) {
const clean = stripJsNoise(source);
const names = new Set();
// 对象键:{ 或 , 之后的 name:(data 初始字段、setData 键、回调键)
for (const match of clean.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)) {
names.add(match[1]);
}
// 方法定义:行首可带 async 的 name(
for (const match of clean.matchAll(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
names.add(match[1]);
}
return names;
}
function main() {
if (!fs.existsSync(pagesPath)) {
console.error(`[check-bindings] 找不到页面目录:${pagesPath}`);
process.exitCode = 2;
return;
}
const pages = listPageNames();
const problems = [];
const legacyHits = [];
let checkedRefs = 0;
for (const name of pages) {
const dir = path.join(pagesPath, name);
const wxmlFile = path.join(dir, `${name}.wxml`);
const jsFile = path.join(dir, `${name}.js`);
const { loopVars, handlers, roots } = collectWxmlRefs(fs.readFileSync(wxmlFile, "utf8"));
const known = collectJsNames(fs.readFileSync(jsFile, "utf8"));
const ignored = new Set([...DEFAULT_LOOP_VARS, ...loopVars]);
const wxmlRel = path.relative(projectPath, wxmlFile);
const jsRel = path.relative(projectPath, jsFile);
for (const handler of handlers) {
checkedRefs++;
if (!known.has(handler.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${handler.ref}`)) {
legacyHits.push(`${wxmlRel}:${handler.line} ${handler.ref}`);
} else {
problems.push(`${wxmlRel}:${handler.line} ${handler.ref} 在 ${jsRel} 中找不到对应方法`);
}
}
}
for (const root of roots) {
if (ignored.has(root.name)) {
continue;
}
checkedRefs++;
if (!known.has(root.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${root.ref}`)) {
legacyHits.push(`${wxmlRel}:${root.line} ${root.ref}`);
} else {
problems.push(`${wxmlRel}:${root.line} ${root.ref} 在 ${jsRel} 的 data/方法中找不到`);
}
}
}
}
if (problems.length > 0) {
console.error(`[check-bindings] 发现 ${problems.length} 处 WXML 与 JS 不一致的引用:`);
for (const problem of problems) {
console.error(` - ${problem}`);
}
console.error(
"[check-bindings] 请统一 wxml 绑定名与页面 JS 的 data 字段/方法名,避免点击与数据绑定静默失效。",
);
}
if (legacyHits.length > 0) {
console.warn(
`[check-bindings] 另有 ${legacyHits.length} 处历史遗留漂移(引入检查前已存在,不计入本次失败,另行跟进):`,
);
for (const hit of legacyHits) {
console.warn(` - ${hit}`);
}
}
if (problems.length > 0) {
process.exitCode = 1;
return;
}
console.log(
`[check-bindings] 通过:${pages.length} 个页面、${checkedRefs} 处 bind/catch 与 {{}} 引用均能在同名 JS 中找到定义。`,
);
}
main();
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "祈福小助手 - 万年历微信小程序",
"private": true,
"scripts": {
"test": "echo \"No tests yet\"",
"test": "node --test utils/core/__tests__/index.test.js && node ci/check-bindings.cjs",
"upload": "node ci/upload-ci.cjs",
"preview": "node ci/preview-ci.cjs"
},
+1 -1
View File
@@ -1,4 +1,4 @@
{
"navigationBarTitleText": "占卜",
"navigationBarTitleText": "民俗趣算",
"enablePullDownRefresh": false
}
+9 -3
View File
@@ -1,4 +1,10 @@
<view class="container">
<!-- 文化声明 -->
<view class="culture-card">
<view class="culture-title">民俗趣算 · 传统文化</view>
<view class="culture-text">梅花易数与称骨歌诀源自古代典籍与民间传统文化,属于传统民俗知识与趣味体验,仅供学习参考与娱乐,不构成任何决策建议,请理性看待,切勿迷信。</view>
</view>
<!-- 梅花易数 -->
<view class="card">
<view class="section-title">梅花易数</view>
@@ -48,9 +54,9 @@
</view>
</view>
<!-- 称骨算命 -->
<!-- 称骨测算 -->
<view class="card mt-2">
<view class="section-title">称骨算命</view>
<view class="section-title">称骨测算</view>
<view class="form-item">
<text class="form-label">出生年份(干支)</text>
<picker mode="selector" range="{{yearGanzhiList}}" value="{{boneYearIndex}}" bindchange="onBoneYearChange">
@@ -75,7 +81,7 @@
<view class="picker-input">{{hourList[boneHourIndex]}}</view>
</picker>
</view>
<view class="btn-primary" bindtap="calcBone">算命</view>
<view class="btn-primary" bindtap="calcBone">测算</view>
</view>
<!-- 称骨结果 -->
+21
View File
@@ -50,3 +50,24 @@
.trigram-name {
font-size: 28rpx;
}
/* 文化声明 */
.culture-card {
background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%);
border-radius: 24rpx;
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
}
.culture-title {
color: #FFFFFF;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 12rpx;
}
.culture-text {
color: rgba(255, 255, 255, 0.92);
font-size: 24rpx;
line-height: 1.7;
}
+6
View File
@@ -1,4 +1,10 @@
<view class="container">
<!-- 文化声明 -->
<view class="culture-card">
<view class="culture-title">每日运势 · 传统文化参考</view>
<view class="culture-text">本页内容基于传统干支历法与五行文化的趣味化呈现,仅供休闲娱乐与传统文化学习参考,不构成任何人生决策建议,请理性看待,切勿迷信。</view>
</view>
<!-- 无八字提示 -->
<view class="card text-center" wx:if="{{!hasBazi}}">
<text class="text-lg text-muted">请先排八字以查看每日运势</text>
+21
View File
@@ -45,3 +45,24 @@
.suggestion-item {
padding: 12rpx 0;
}
/* 文化声明 */
.culture-card {
background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%);
border-radius: 24rpx;
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
}
.culture-title {
color: #FFFFFF;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 12rpx;
}
.culture-text {
color: rgba(255, 255, 255, 0.92);
font-size: 24rpx;
line-height: 1.7;
}
+6 -6
View File
@@ -5,7 +5,7 @@ const birthProfile = require('../../utils/birth-profile.js');
Page({
data: {
userInfo: null,
loggedIn: false,
logged: false,
logging: false,
// 菜单角标
profileCount: 0,
@@ -25,7 +25,7 @@ Page({
refreshUser() {
const token = wx.getStorageSync('token');
const userInfo = wx.getStorageSync('lunar-user-info');
this.setData({ loggedIn: !!token, userInfo: userInfo || null });
this.setData({ logged: !!token, userInfo: userInfo || null });
},
refreshCounts() {
@@ -39,7 +39,7 @@ Page({
// ==================== 登录 ====================
login() {
wxLogin() {
if (this.data.logging) return;
this.setData({ logging: true });
wx.login({
@@ -57,7 +57,7 @@ Page({
if (res && res.code === 0 && res.data && res.data.token) {
wx.setStorageSync('token', res.data.token);
wx.setStorageSync('lunar-user-info', res.data.user);
this.setData({ userInfo: res.data.user, loggedIn: true, logging: false });
this.setData({ userInfo: res.data.user, logged: true, logging: false });
wx.showToast({ title: '登录成功', icon: 'success' });
// 登录后拉取云端生辰档案,保证菜单计数准确
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
@@ -85,13 +85,13 @@ Page({
if (!res.confirm) return;
wx.removeStorageSync('token');
wx.removeStorageSync('lunar-user-info');
this.setData({ userInfo: null, loggedIn: false });
this.setData({ userInfo: null, logged: false });
wx.showToast({ title: '已退出', icon: 'success' });
}
});
},
navTo(e) {
navigateTo(e) {
const url = e.currentTarget.dataset.url;
// tabBar 页面必须用 switchTab 打开
const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings'];
+10 -10
View File
@@ -1,7 +1,7 @@
<view class="container">
<!-- 登录与账号 -->
<view class="card">
<view class="flex items-center justify-between" wx:if="{{loggedIn}}">
<view class="flex items-center justify-between" wx:if="{{logged}}">
<view class="flex items-center gap-2">
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
<view class="avatar avatar-placeholder" wx:else>
@@ -24,28 +24,28 @@
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
</view>
</view>
<view class="btn-primary btn-sm" bindtap="login">{{logging ? '登录中…' : '授权登录'}}</view>
<view class="btn-primary btn-sm" bindtap="wxLogin">{{logging ? '登录中…' : '授权登录'}}</view>
</view>
</view>
<!-- 我的数据 -->
<view class="card">
<view class="section-title">我的数据</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/birth-profiles/birth-profiles">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/birth-profiles/birth-profiles">
<text class="menu-label">生辰管理</text>
<view class="menu-right">
<text class="menu-count">{{profileCount}}/{{maxFree}}</text>
<text class="menu-arrow">›</text>
</view>
</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/bookmarks/bookmarks">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/bookmarks/bookmarks">
<text class="menu-label">我的收藏</text>
<view class="menu-right">
<text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text>
<text class="menu-arrow">›</text>
</view>
</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/preferences/preferences">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/preferences/preferences">
<text class="menu-label">设置</text>
<text class="menu-arrow">›</text>
</view>
@@ -54,19 +54,19 @@
<!-- 功能 -->
<view class="card">
<view class="section-title">功能</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/fortune/fortune">
<text class="menu-label">每日运势</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/divination/divination">
<text class="menu-label">占卜</text>
<view class="menu-item" bindtap="navigateTo" data-url="/pages/divination/divination">
<text class="menu-label">民俗趣算</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/solar-terms/solar-terms">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/solar-terms/solar-terms">
<text class="menu-label">二十四节气</text>
<text class="menu-arrow">›</text>
</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/calendar/calendar">
<view class="menu-item" bindtap="navigateTo" data-url="/pages/calendar/calendar">
<text class="menu-label">月历视图</text>
<text class="menu-arrow">›</text>
</view>
+17 -100
View File
@@ -54,11 +54,7 @@ Page({
fireflies: [],
showCreateModal: false,
wishContent: '',
wishType: 'free',
maxFreeLength: 20,
maxPaidLength: 100,
products: [],
selectedProduct: null,
},
onLoad() {
@@ -72,7 +68,6 @@ Page({
this.initAmbiance();
this.loadTrees();
this.loadProducts();
},
onUnload() {
@@ -407,7 +402,7 @@ Page({
id: i + 1,
content: texts[i % texts.length],
author: authors[i % authors.length],
type: i % 5 === 0 ? 'paid' : 'free',
type: 'free',
position: i === 0 ? 100 : 0,
isTop: i === 0,
createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(),
@@ -427,26 +422,6 @@ Page({
this.setData({ detailWish: null });
},
// 加载许愿商品
async loadProducts() {
try {
const res = await request({ url: '/api/wish/products', method: 'GET' });
if (res.code === 0) {
this.setData({ products: res.data.list || [] });
return;
}
} catch (err) {
console.error('加载商品失败:', err);
}
this.setData({
products: [
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
],
});
},
// 打开/关闭许愿弹窗
openCreateModal() {
this.setData({ showCreateModal: true });
@@ -456,8 +431,6 @@ Page({
this.setData({
showCreateModal: false,
wishContent: '',
wishType: 'free',
selectedProduct: null,
});
},
@@ -465,16 +438,6 @@ Page({
this.setData({ wishContent: e.detail.value });
},
selectType(e) {
this.setData({ wishType: e.currentTarget.dataset.type });
},
selectProduct(e) {
const id = e.currentTarget.dataset.id;
const product = this.data.products.find((p) => p.id === id);
this.setData({ selectedProduct: product });
},
// 当前树ID
currentTreeId() {
const { trees, currentTreeIndex } = this.data;
@@ -484,54 +447,32 @@ Page({
// 提交许愿
async submitWish() {
const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data;
const { wishContent, maxFreeLength } = this.data;
if (!wishContent.trim()) {
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
return;
}
const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength;
if (wishContent.length > maxLength) {
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
return;
}
if (wishType === 'paid' && !selectedProduct) {
wx.showToast({ title: '请选择许愿商品', icon: 'none' });
if (wishContent.length > maxFreeLength) {
wx.showToast({ title: `最多输入${maxFreeLength}个字`, icon: 'none' });
return;
}
try {
if (wishType === 'paid') {
const orderRes = await request({
url: '/api/pay/create',
method: 'POST',
data: {
type: 'wish',
productId: selectedProduct.id,
content: wishContent,
treeId: this.currentTreeId(),
},
});
if (orderRes.code === 0) {
await this.wxPay(orderRes.data);
}
} else {
const res = await request({
url: '/api/wish/create',
method: 'POST',
data: {
content: wishContent,
type: 'free',
treeId: this.currentTreeId(),
},
});
if (res.code === 0) {
wx.showToast({ title: '许愿成功', icon: 'success' });
this.closeCreateModal();
this.loadTreeWishes(this.data.currentTreeIndex, true);
}
const res = await request({
url: '/api/wish/create',
method: 'POST',
data: {
content: wishContent,
type: 'free',
treeId: this.currentTreeId(),
},
});
if (res.code === 0) {
wx.showToast({ title: '许愿成功', icon: 'success' });
this.closeCreateModal();
this.loadTreeWishes(this.data.currentTreeIndex, true);
}
} catch (err) {
console.error('许愿失败:', err);
@@ -539,30 +480,6 @@ Page({
}
},
// 微信支付
async wxPay(orderData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: orderData.timeStamp,
nonceStr: orderData.nonceStr,
package: orderData.package,
signType: orderData.signType,
paySign: orderData.paySign,
success: () => {
wx.showToast({ title: '支付成功', icon: 'success' });
this.closeCreateModal();
this.loadTreeWishes(this.data.currentTreeIndex, true);
resolve();
},
fail: (err) => {
console.error('支付失败:', err);
wx.showToast({ title: '支付失败', icon: 'none' });
reject(err);
},
});
});
},
// 分享
onShareAppMessage() {
return {
+3 -45
View File
@@ -122,64 +122,22 @@
</view>
<view class="modal-body">
<!-- 许愿类型选择 -->
<view class="type-selector">
<view
class="type-item {{wishType === 'free' ? 'active' : ''}}"
bindtap="selectType"
data-type="free"
>
<text class="type-name">免费许愿</text>
<text class="type-desc">20字以内</text>
</view>
<view
class="type-item {{wishType === 'paid' ? 'active' : ''}}"
bindtap="selectType"
data-type="paid"
>
<text class="type-name">付费许愿</text>
<text class="type-desc">更多特权</text>
</view>
</view>
<!-- 许愿内容输入 -->
<textarea
class="wish-input"
placeholder="请输入你的愿望..."
maxlength="{{wishType === 'free' ? maxFreeLength : maxPaidLength}}"
maxlength="{{maxFreeLength}}"
bindinput="onInputContent"
value="{{wishContent}}"
></textarea>
<view class="input-count">
{{wishContent.length}}/{{wishType === 'free' ? maxFreeLength : maxPaidLength}}
</view>
<!-- 付费商品选择 -->
<view class="product-section" wx:if="{{wishType === 'paid'}}">
<view class="product-title">选择许愿商品</view>
<view class="product-list">
<view
wx:for="{{products}}"
wx:key="id"
class="product-item {{selectedProduct && selectedProduct.id === item.id ? 'active' : ''}}"
bindtap="selectProduct"
data-id="{{item.id}}"
>
<view class="product-info">
<text class="product-name">{{item.name}}</text>
<text class="product-desc">展示{{item.duration}}天</text>
</view>
<view class="product-price">¥{{item.price / 100}}</view>
</view>
</view>
{{wishContent.length}}/{{maxFreeLength}}
</view>
</view>
<view class="modal-footer">
<button class="btn-outline" bindtap="closeCreateModal">取消</button>
<button class="btn-primary" bindtap="submitWish">
{{wishType === 'paid' ? '支付并许愿' : '提交许愿'}}
</button>
<button class="btn-primary" bindtap="submitWish">提交许愿</button>
</view>
</view>
</view>
+518
View File
@@ -0,0 +1,518 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
solarToLunar,
lunarToSolar,
getGanzhi,
getYearGanzhi,
getMonthGanzhi,
getHourGanzhi,
getZodiac,
getConstellation,
getSolarTerm,
getNayin,
getSixStar,
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
daysBetween,
getWeekDay,
getBuddhistFestival,
getLegalHoliday,
isWorkday,
analyzeElementBalance,
calculateBoneWeight,
analyzeFortuneGanzhi,
HEAVEN_STEMS,
EARTH_BRANCHES,
ZODIACS,
LUNAR_MONTH_NAMES,
LUNAR_DAY_NAMES,
WEEK_NAMES,
SOLAR_TERMS,
HOUR_NAMES,
HOUR_RANGES
} = require('../index.js');
// ─── 常量数据完整性 ───────────────────────────────────────────
describe('常量数据完整性', () => {
it('天干应有 10 个', () => {
assert.equal(HEAVEN_STEMS.length, 10);
assert.equal(HEAVEN_STEMS[0], '甲');
assert.equal(HEAVEN_STEMS[9], '癸');
});
it('地支应有 12 个', () => {
assert.equal(EARTH_BRANCHES.length, 12);
assert.equal(EARTH_BRANCHES[0], '子');
assert.equal(EARTH_BRANCHES[11], '亥');
});
it('生肖应有 12 个', () => {
assert.equal(ZODIACS.length, 12);
assert.equal(ZODIACS[0], '鼠');
});
it('农历月份名应有 12 个', () => {
assert.equal(LUNAR_MONTH_NAMES.length, 12);
});
it('农历日期名应有 30 个', () => {
assert.equal(LUNAR_DAY_NAMES.length, 30);
});
it('星期名应有 7 个', () => {
assert.equal(WEEK_NAMES.length, 7);
});
it('节气应有 24 个', () => {
assert.equal(SOLAR_TERMS.length, 24);
});
it('时辰名和时辰范围数量一致', () => {
assert.equal(HOUR_NAMES.length, HOUR_RANGES.length);
});
});
// ─── 公历基础计算 ─────────────────────────────────────────────
describe('getWeekDay', () => {
it('2024-01-01 是周一', () => {
assert.equal(getWeekDay(2024, 1, 1), 1);
});
it('2024-03-17 是周日', () => {
assert.equal(getWeekDay(2024, 3, 17), 0);
});
});
describe('daysBetween', () => {
it('同一天差 0', () => {
assert.equal(daysBetween(2024, 6, 15, 2024, 6, 15), 0);
});
it('跨月计算', () => {
assert.equal(daysBetween(2024, 1, 1, 2024, 2, 1), 31);
});
it('跨年计算', () => {
assert.equal(daysBetween(2024, 1, 1, 2025, 1, 1), 366); // 2024 闰年
});
it('反向为负值', () => {
assert.equal(daysBetween(2025, 1, 1, 2024, 1, 1), -366);
});
});
// ─── 公历转农历 ──────────────────────────────────────────────
describe('solarToLunar', () => {
it('1900-01-31 = 农历1900年正月初一', () => {
const r = solarToLunar(1900, 1, 31);
assert.deepEqual(r, { year: 1900, month: 1, day: 1, isLeap: false });
});
it('2024-02-10 = 农历2024年正月初一(春节)', () => {
const r = solarToLunar(2024, 2, 10);
assert.equal(r.year, 2024);
assert.equal(r.month, 1);
assert.equal(r.day, 1);
assert.equal(r.isLeap, false);
});
it('2023-01-22 = 农历2023年正月初一', () => {
const r = solarToLunar(2023, 1, 22);
assert.equal(r.year, 2023);
assert.equal(r.month, 1);
assert.equal(r.day, 1);
});
it('超出范围返回 null', () => {
assert.equal(solarToLunar(1899, 1, 1), null);
assert.equal(solarToLunar(2101, 1, 1), null);
});
it('农历日期day字段从1开始', () => {
const r = solarToLunar(2024, 2, 11);
assert.equal(r.day, 2); // 正月初二
});
});
// ─── 农历转公历 ──────────────────────────────────────────────
describe('lunarToSolar', () => {
it('农历1900年正月初一 = 1900-01-31', () => {
const r = lunarToSolar(1900, 1, 1);
assert.deepEqual(r, { year: 1900, month: 1, day: 31 });
});
it('农历2024年正月初一 = 2024-02-10', () => {
const r = lunarToSolar(2024, 1, 1);
assert.deepEqual(r, { year: 2024, month: 2, day: 10 });
});
it('超出范围返回 null', () => {
assert.equal(lunarToSolar(1899, 1, 1), null);
});
it('互逆验证:solarToLunar → lunarToSolar 往返一致', () => {
const solar = { year: 2025, month: 6, day: 15 };
const lunar = solarToLunar(solar.year, solar.month, solar.day);
const back = lunarToSolar(lunar.year, lunar.month, lunar.day, lunar.isLeap);
assert.deepEqual(back, solar);
});
});
// ─── 干支计算 ────────────────────────────────────────────────
describe('getYearGanzhi', () => {
it('2024年 = 甲辰年', () => {
const r = getYearGanzhi(2024);
assert.equal(r.ganzhi, '甲辰');
assert.equal(r.stem, '甲');
assert.equal(r.branch, '辰');
});
it('2023年 = 癸卯年', () => {
assert.equal(getYearGanzhi(2023).ganzhi, '癸卯');
});
it('1900年 = 庚子年', () => {
assert.equal(getYearGanzhi(1900).ganzhi, '庚子');
});
it('60年一甲子循环', () => {
const a = getYearGanzhi(2024);
const b = getYearGanzhi(2024 - 60);
assert.equal(a.ganzhi, b.ganzhi);
});
});
describe('getGanzhi(日干支)', () => {
it('返回 stem/branch/ganzhi 三个字段', () => {
const r = getGanzhi(2024, 1, 1);
assert.ok(r.stem);
assert.ok(r.branch);
assert.equal(r.ganzhi, r.stem + r.branch);
});
it('连续两天的日干支差一天', () => {
const a = getGanzhi(2024, 1, 1);
const b = getGanzhi(2024, 1, 2);
const stemA = HEAVEN_STEMS.indexOf(a.stem);
const stemB = HEAVEN_STEMS.indexOf(b.stem);
assert.equal((stemB - stemA + 10) % 10, 1);
});
});
describe('getMonthGanzhi', () => {
it('返回完整干支', () => {
const r = getMonthGanzhi(2024, 1, 15);
assert.ok(r.stem.length === 1);
assert.ok(r.branch.length === 1);
assert.equal(r.ganzhi.length, 2);
});
});
describe('getHourGanzhi', () => {
it('子时(0点)返回有效干支', () => {
const dayGz = getGanzhi(2024, 1, 1);
const r = getHourGanzhi(dayGz, 0);
assert.ok(r.ganzhi.length === 2);
assert.equal(r.branch, '子');
});
it('午时(12点) branch 为午', () => {
const dayGz = getGanzhi(2024, 1, 1);
const r = getHourGanzhi(dayGz, 12);
assert.equal(r.branch, '午');
});
});
// ─── 生肖与星座 ──────────────────────────────────────────────
describe('getZodiac', () => {
it('2024 = 龙', () => {
assert.equal(getZodiac(2024), '龙');
});
it('2023 = 兔', () => {
assert.equal(getZodiac(2023), '兔');
});
it('1900 = 鼠', () => {
assert.equal(getZodiac(1900), '鼠');
});
it('12年循环', () => {
assert.equal(getZodiac(2024), getZodiac(2024 + 12));
});
});
describe('getConstellation', () => {
it('1月15日 = 摩羯座', () => {
assert.equal(getConstellation(1, 15), '摩羯座');
});
it('2月20日 = 双鱼座(边界日之后)', () => {
assert.equal(getConstellation(2, 20), '双鱼座');
});
it('3月21日 = 白羊座(春分边界)', () => {
assert.equal(getConstellation(3, 21), '白羊座');
});
it('12月22日 = 摩羯座', () => {
assert.equal(getConstellation(12, 22), '摩羯座');
});
});
// ─── 月相(通过 getDayInfo 间接验证) ───────────────────────
describe('getDayInfo 月相字段', () => {
it('正月初一附近返回朔月', () => {
// 2024-02-10 是农历正月初一
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 2, 10);
assert.equal(r.moonPhase, '朔月');
});
it('正月十五返回满月', () => {
// 2024-02-24 是农历正月十五
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 2, 24);
assert.equal(r.moonPhase, '满月');
});
});
// ─── 纳音 ─────────────────────────────────────────────────────
describe('getNayin', () => {
it('甲子 = 海中金', () => {
assert.equal(getNayin('甲子'), '海中金');
});
it('返回字符串', () => {
const r = getNayin('丙寅');
assert.equal(typeof r, 'string');
assert.ok(r.length > 0);
});
});
// ─── 六曜(通过 getDayInfo 间接验证) ───────────────────────
describe('getDayInfo 六曜字段', () => {
it('返回有效结构', () => {
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 1, 1);
assert.ok(r.lunarMonth > 0);
assert.ok(r.lunarDay > 0);
});
});
// ─── 胎神/宜忌/冲煞/彭祖(通过 getAlmanacInfo 间接验证) ────
describe('getAlmanacInfo 黄历字段', () => {
it('返回完整黄历结构', () => {
const { getAlmanacInfo } = require('../index.js');
const r = getAlmanacInfo(2024, 3, 18);
assert.ok(r.duty);
assert.ok(['good', 'bad', 'neutral'].includes(r.dutyLuck));
assert.ok(r.twelveStar);
assert.ok(typeof r.pengZu === 'string');
assert.ok(typeof r.clash === 'string');
assert.ok(typeof r.harm === 'string');
assert.ok(typeof r.combine === 'string');
assert.ok(['北', '南', '东', '西'].includes(r.evilDirection));
assert.ok(Array.isArray(r.recommends));
assert.ok(Array.isArray(r.avoids));
assert.ok(r.fetus);
});
});
// ─── 地支关系 ────────────────────────────────────────────────
describe('getBranchRelationship', () => {
it('子丑合', () => {
const r = getBranchRelationship('子', '丑');
assert.equal(r.combine, true);
});
it('子午冲', () => {
const r = getBranchRelationship('子', '午');
assert.equal(r.opposite, true);
});
it('相同地支为合', () => {
// 子丑合,所以子和丑是合关系
const r = getBranchRelationship('子', '丑');
assert.equal(r.combine, true);
});
it('申子辰三合水局', () => {
const r = getBranchRelationship('申', '子');
assert.equal(r.threeCombine, true);
assert.equal(r.formation, '水局');
});
});
// ─── 十神关系 ────────────────────────────────────────────────
describe('getTenStarRelationship', () => {
it('同干为比肩', () => {
assert.equal(getTenStarRelationship('甲', '甲'), '比肩');
});
it('甲见乙 = 劫财', () => {
assert.equal(getTenStarRelationship('甲', '乙'), '劫财');
});
it('甲见丙 = 食神', () => {
assert.equal(getTenStarRelationship('甲', '丙'), '食神');
});
});
// ─── 天干合冲 ────────────────────────────────────────────────
describe('checkStemCombine', () => {
it('甲己合', () => {
assert.equal(checkStemCombine('甲', '己'), true);
});
it('甲乙不合', () => {
assert.equal(checkStemCombine('甲', '乙'), false);
});
});
describe('checkStemOpposite', () => {
it('甲庚冲', () => {
assert.equal(checkStemOpposite('甲', '庚'), true);
});
it('甲乙不冲', () => {
assert.equal(checkStemOpposite('甲', '乙'), false);
});
});
// ─── 佛教节日 ────────────────────────────────────────────────
describe('getBuddhistFestival', () => {
it('腊月初八有佛教节日', () => {
// 腊八节是佛教节日
const r = getBuddhistFestival(12, 8);
// 可能有也可能没有,只验证返回类型
assert.ok(r === null || typeof r === 'string');
});
it('不存在的日期返回 null', () => {
assert.equal(getBuddhistFestival(6, 15), null);
});
});
// ─── 工作日判断 ──────────────────────────────────────────────
describe('isWorkday', () => {
it('普通周一为工作日', () => {
// 2024-03-18 是周一,非节假日
assert.equal(isWorkday(2024, 3, 18), true);
});
it('普通周六为非工作日', () => {
// 2024-03-16 是周六
assert.equal(isWorkday(2024, 3, 16), false);
});
it('普通周日为非工作日', () => {
// 2024-03-17 是周日
assert.equal(isWorkday(2024, 3, 17), false);
});
});
// ─── 五行分析 ────────────────────────────────────────────────
describe('analyzeElementBalance', () => {
it('四柱全木为不平衡', () => {
const bazi = {
yearPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
monthPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
dayPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
hourPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] }
};
const r = analyzeElementBalance(bazi);
assert.ok(r.wood > 0);
assert.equal(r.fire, 0);
assert.equal(r.isBalanced, false);
assert.equal(r.dominant, '木');
});
it('返回 wood/fire/earth/metal/water/total/dominant/weakest/isBalanced', () => {
const bazi = {
yearPillar: { elementStem: '木', elementBranch: '火', hideStems: [] },
monthPillar: { elementStem: '土', elementBranch: '金', hideStems: [] },
dayPillar: { elementStem: '水', elementBranch: '木', hideStems: [] },
hourPillar: { elementStem: '火', elementBranch: '土', hideStems: [] }
};
const r = analyzeElementBalance(bazi);
assert.equal(typeof r.total, 'number');
assert.ok(r.total > 0);
assert.ok(['木', '火', '土', '金', '水'].includes(r.dominant));
assert.ok(['木', '火', '土', '金', '水'].includes(r.weakest));
});
});
// ─── 称骨算命 ────────────────────────────────────────────────
describe('calculateBoneWeight', () => {
it('返回完整结构', () => {
const r = calculateBoneWeight(0, 1, 1, 0);
assert.ok(typeof r.yearWeight === 'number');
assert.ok(typeof r.monthWeight === 'number');
assert.ok(typeof r.dayWeight === 'number');
assert.ok(typeof r.hourWeight === 'number');
assert.ok(typeof r.totalWeight === 'number');
assert.equal(r.totalWeight, r.yearWeight + r.monthWeight + r.dayWeight + r.hourWeight);
assert.ok(typeof r.interpretation === 'string');
assert.ok(typeof r.fortune === 'string');
});
it('totalLiang 和 totalQian 与 totalWeight 一致', () => {
const r = calculateBoneWeight(5, 3, 10, 4);
assert.equal(r.totalLiang * 10 + r.totalQian, r.totalWeight);
});
});
// ─── 大运/流年分析 ───────────────────────────────────────────
describe('analyzeFortuneGanzhi', () => {
it('返回完整分析结构', () => {
const r = analyzeFortuneGanzhi('甲子', '甲', '子');
assert.equal(r.ganzhi, '甲子');
assert.ok(r.tenStar);
assert.ok(r.elementRelation);
assert.ok(['吉', '平', '凶'].includes(r.level));
assert.ok(typeof r.score === 'number');
});
it('同干同支 score 不为极端值', () => {
const r = analyzeFortuneGanzhi('甲子', '甲', '子');
assert.ok(r.score >= -10 && r.score <= 10);
});
});
// ─── 节气查询 ────────────────────────────────────────────────
describe('getSolarTerm', () => {
it('非节气日返回 null', () => {
// 随机选一个非节气日
const r = getSolarTerm(2024, 6, 15);
assert.equal(r, null);
});
it('返回值类型为字符串或 null', () => {
const r = getSolarTerm(2024, 3, 20); // 春分附近
assert.ok(r === null || typeof r === 'string');
});
});
+19 -7
View File
@@ -1,6 +1,11 @@
# 知命 / 祈福后端服务
基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。
基于 Go、Gin、GORM 的后端仓库,包含两个部署入口:
- `cmd/mini`:小程序接口(lunar.neatcn.com)——微信登录、生辰档案、许愿树、百科。
- `cmd/web`:商业版 Web(kepo.net)——邮箱验证码登录、Stripe / Binance Pay、订单权益、报告快照、管理后台与商业前端静态站点。
两侧共用 `internal/` 下的配置、数据库、模型与中间件,但路由互不暴露。
## 功能模块
@@ -20,12 +25,18 @@
```bash
cp .env.example .env.local
go mod download
go run ./cmd
go run ./cmd/mini # 小程序接口
go run ./cmd/web # 商业版 Web(本地需 ../web-commercial/dist 与 ./web 静态资源)
```
默认地址:`http://localhost:8080`。
生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。
生产部署:
- **小程序接口**(doc79):CI 构建 `server-v*` Release,服务器 watcher 自动拉取,Docker 容器运行。见 `.gitea/docs/PULL_DEPLOY.md`。
- **商业版 Web**(kepo.net):CI 构建 `web-v*` Release(含 `cmd/web` 二进制 + `frontend/` 商业前端 + `web/` 后台静态资源),systemd 运行。一次性配置见 `.gitea/docs/PULL_DEPLOY_WEB.md`。
商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。
## 测试
@@ -53,8 +64,11 @@ go test ./...
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
- `GET /api/commerce/reports` — 本人已购报告。
- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。
- `POST /api/commerce/charts` — 用户主动同意后保存免费排盘记录;接口限流并记录同意版本与时间。
除 Webhook 外,商业接口都要求 `Authorization: Bearer <JWT>`。
除 Webhook 和需同意的公开排盘记录接口外,商业接口都要求 `Authorization: Bearer <JWT>`。
免费排盘默认不上传。创建付费订单时,报告快照会发送并保存在服务端,用于生成、交付和恢复已购报告。
## 管理后台
@@ -78,14 +92,12 @@ go test ./...
## 数据库
新安装由 GORM AutoMigrate 创建表。已有 MySQL 数据库先备份,再单独执行:
新安装由 GORM AutoMigrate 创建表。商业排盘记录新增同意版本和时间字段,服务启动时通过 AutoMigrate 增量添加;已有 MySQL 数据库应先备份再升级,不要执行数据库 fresh 或 refresh。商业表结构初始化脚本仍为:
```bash
mysql < migrations/002_commerce.sql
```
不要执行数据库 fresh 或 refresh。
## 环境变量
完整模板见 `.env.example`。邮箱登录配置 QQ SMTP:
-199
View File
@@ -1,199 +0,0 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
"github.com/joho/godotenv"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed assets/images
var assetsFS embed.FS
func main() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
_ = godotenv.Load(".env.local", "../.env.local", ".env")
// 将构建时注入的版本信息传递给 handler
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
// 加载配置
cfg := config.Load()
// 初始化数据库
if err := config.InitDB(cfg); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 设置运行模式
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由(gin.Default 自带访问日志与 Recovery)
r := gin.Default()
// 中间件
r.Use(middleware.CORS())
// 商业版前端静态文件、旧管理后台静态资源与模板
// 商业站点部署在根路径;后台仍通过 /admin 访问。
r.Static("/assets", "./frontend/assets")
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
r.Static("/static", "./web/static")
r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(许愿树背景图等)
if imagesFS, err := fs.Sub(assetsFS, "assets/images"); err == nil {
r.GET("/images/*path", gin.WrapH(http.StripPrefix("/images", http.FileServer(http.FS(imagesFS)))))
}
// API 路由
api := r.Group("/api")
{
// 版本信息(无需认证)
api.GET("/version", handler.GetVersion)
auth := api.Group("/auth")
{
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
}
commerce := api.Group("/commerce")
{
commerce.POST("/charts", handler.SaveChartRecord)
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
}
// 用户相关
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
}
// 订单相关
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
// 许愿相关
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
// 百科/历史上的今天(无需认证)
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
// 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
// 百科管理 API
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
authed.GET("/api/orders", handler.AdminCommerceOrders)
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
authed.GET("/api/charts", handler.AdminChartRecords)
// 节气详情同步(百度百科,独立于维基)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
// 启动服务器
addr := ":" + cfg.Server.Port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
// 小程序接口服务(lunar.neatcn.com):微信登录、许愿树、百科等。
// 不包含商业版支付、邮箱验证码登录与管理后台。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-mini-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupMini(r, cfg)
app.Run(r, cfg.Server.Port)
}
+31
View File
@@ -0,0 +1,31 @@
// 商业版 Web 服务(kepo.net):邮箱验证码登录、Stripe / Binance Pay
// 支付、订单权益、报告快照与恢复码、管理后台、商业前端静态站点。
// 不包含小程序接口(微信登录、许愿树、百科同步等)。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-web-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupWeb(r, cfg)
app.Run(r, cfg.Server.Port)
}
+59
View File
@@ -0,0 +1,59 @@
// Package app 提供 lunar-server 两个部署形态(小程序接口 / 商业版 Web)
// 共用的启动逻辑与路由注册。
package app
import (
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/assets"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/joho/godotenv"
)
// Bootstrap 公共启动逻辑:加载 env、配置与数据库连接
func Bootstrap() *config.Config {
_ = godotenv.Load(".env.local", "../.env.local", ".env")
cfg := config.Load()
// 生产环境安全底线:JWT 密钥必须是强随机值
if cfg.Server.Env == "production" {
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-secret-key" || len(cfg.JWT.Secret) < 32 {
log.Fatal("JWT_SECRET 未配置或过短:生产环境必须设置至少 32 位的强随机密钥")
}
}
if err := config.InitDB(cfg); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
return cfg
}
// NewEngine 创建带通用中间件的 HTTP 引擎
func NewEngine() *gin.Engine {
r := gin.Default()
r.Use(middleware.CORS())
return r
}
// MountImages 挂载内嵌图片资源(许愿树背景图等)
func MountImages(r *gin.Engine) {
if imagesFS, err := fs.Sub(assets.FS, "images"); err == nil {
r.GET("/images/*path", gin.WrapH(http.StripPrefix("/images", http.FileServer(http.FS(imagesFS)))))
}
}
// Run 启动 HTTP 服务
func Run(r *gin.Engine, port string) {
addr := ":" + port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
)
// SetupMini 注册小程序接口路由与定时任务。
// 不包含商业版支付、邮箱登录与管理后台——这些只属于 cmd/web。
func SetupMini(r *gin.Engine, cfg *config.Config) {
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 内嵌图片资源(许愿树背景图等)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关(许愿付费;演示期保留接口占位)
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
}
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package app
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
)
// SetupWeb 注册商业版 Web 路由:邮箱登录、支付/订单、报告权益、
// 管理后台与商业前端静态文件。不包含小程序接口(许愿树、微信登录等)。
//
// 静态资源约定(相对 WorkingDirectory):
//
// ./frontend/ 商业前端构建产物(web-commercial/dist)
// ./web/ 管理后台静态资源与模板(server/web)
func SetupWeb(r *gin.Engine, cfg *config.Config) {
// 商业版前端静态文件;后台仍通过 /admin 访问。
r.Static("/assets", "./frontend/assets")
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
r.Static("/static", "./web/static")
r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(管理后台可能引用许愿树图片)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
auth := api.Group("/auth")
{
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
}
commerce := api.Group("/commerce")
{
commerce.POST("/charts", handler.SaveChartRecord)
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
authed.GET("/api/orders", handler.AdminCommerceOrders)
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
authed.GET("/api/charts", handler.AdminChartRecords)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
// SPA 回退:非 /api、/admin、/static、/images、/assets 的 GET 请求
// 一律返回商业前端 index.html,交给前端路由处理。
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
for _, prefix := range []string{"/api/", "/admin", "/static/", "/images/", "/assets/"} {
if strings.HasPrefix(path, prefix) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "not found"})
return
}
}
if _, err := os.Stat("./frontend/index.html"); err == nil {
c.File("./frontend/index.html")
return
}
c.String(http.StatusNotFound, "frontend not deployed")
})
}
+8
View File
@@ -0,0 +1,8 @@
package assets
import "embed"
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed images
var FS embed.FS

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB

+27 -9
View File
@@ -1,6 +1,7 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
@@ -9,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
@@ -21,21 +23,37 @@ var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7]
func SaveChartRecord(c *gin.Context) {
var request struct {
ReportID string `json:"reportId" binding:"required"`
Name string `json:"name"`
BirthDate string `json:"birthDate" binding:"required"`
BirthTime string `json:"birthTime" binding:"required"`
Gender string `json:"gender" binding:"required"`
City string `json:"city" binding:"required"`
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
ReportID string `json:"reportId" binding:"required"`
Name string `json:"name"`
BirthDate string `json:"birthDate" binding:"required"`
BirthTime string `json:"birthTime" binding:"required"`
Gender string `json:"gender" binding:"required"`
City string `json:"city" binding:"required"`
ConsentVersion string `json:"consentVersion" binding:"required"`
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) < 2 || len(request.Snapshot) > 512*1024 || request.Snapshot[0] != '{' {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 600*1024)
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
return
}
snapshot := bytes.TrimSpace(request.Snapshot)
var snapshotObject map[string]json.RawMessage
birthDate, dateErr := time.Parse("2006-01-02", request.BirthDate)
_, timeErr := time.Parse("15:04", request.BirthTime)
if !reportIDPattern.MatchString(request.ReportID) || len(snapshot) < 2 || len(snapshot) > 512*1024 ||
json.Unmarshal(snapshot, &snapshotObject) != nil || snapshotObject == nil ||
dateErr != nil || birthDate.Year() < 1901 || birthDate.Year() > 2100 || timeErr != nil ||
(request.Gender != "male" && request.Gender != "female") || request.ConsentVersion != service.ChartRecordConsentVersion ||
utf8.RuneCountInString(request.Name) > 64 || utf8.RuneCountInString(request.City) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误或未同意保存"})
return
}
consentedAt := time.Now().UTC()
record := &model.BaziChartRecord{
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, Snapshot: string(request.Snapshot),
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City,
ConsentVersion: request.ConsentVersion, ConsentAt: &consentedAt, Snapshot: string(snapshot),
}
if userID, exists := c.Get("userID"); exists {
if value, ok := userID.(uint); ok {
+85
View File
@@ -155,3 +155,88 @@ func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
}
}
func TestSaveChartRecordRequiresConsent(t *testing.T) {
router := setupChartRecordAPI(t)
response := postChartRecord(router, "198.51.100.41", false)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":400`) {
t.Fatalf("expected consent to be required, got %d: %s", response.Code, response.Body.String())
}
var recordCount int64
if err := config.GetDB().Model(&model.BaziChartRecord{}).Count(&recordCount).Error; err != nil {
t.Fatal(err)
}
if recordCount != 0 {
t.Fatalf("expected no chart record without consent, got %d", recordCount)
}
}
func TestSaveChartRecordPersistsConsentViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
response := postChartRecord(router, "198.51.100.42", true)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
t.Fatalf("expected chart record to be saved after consent, got %d: %s", response.Code, response.Body.String())
}
var record model.BaziChartRecord
if err := config.GetDB().Where("report_id = ?", "ZM-CHART123").First(&record).Error; err != nil {
t.Fatal(err)
}
if record.ConsentVersion != "chart-record-v1" || record.ConsentAt == nil {
t.Fatalf("expected persisted consent version and timestamp")
}
}
func TestSaveChartRecordRateLimitedViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
for requestNumber := 0; requestNumber < 11; requestNumber++ {
response := postChartRecord(router, "198.51.100.43", true)
if requestNumber < 10 && response.Code != http.StatusOK {
t.Fatalf("request %d expected HTTP 200, got %d: %s", requestNumber+1, response.Code, response.Body.String())
}
if requestNumber == 10 && response.Code != http.StatusTooManyRequests {
t.Fatalf("expected HTTP 429 after rate limit, got %d: %s", response.Code, response.Body.String())
}
}
}
func TestSaveChartRecordRejectsOversizedBodyViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
body := fmt.Sprintf(`{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","consentVersion":"chart-record-v1","snapshot":{"data":"%s"}}`, strings.Repeat("x", 600*1024))
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.RemoteAddr = "198.51.100.44:1234"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected oversized body to be rejected, got %d", response.Code)
}
}
func setupChartRecordAPI(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
t.Setenv("SERVER_ENV", "test")
t.Setenv("DB_TYPE", "sqlite")
t.Setenv("DB_NAME", fmt.Sprintf("file:chart-consent-http-%d?mode=memory&cache=shared", time.Now().UnixNano()))
t.Setenv("JWT_SECRET", "chart-consent-test-secret")
if err := config.InitDB(config.Load()); err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/api/commerce/charts", middleware.RateLimit(), SaveChartRecord)
return router
}
func postChartRecord(router *gin.Engine, clientIP string, consent bool) *httptest.ResponseRecorder {
body := `{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","snapshot":{"id":"ZM-CHART123"}`
if consent {
body += `,"consentVersion":"chart-record-v1"`
}
body += `}`
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.RemoteAddr = clientIP + ":1234"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
+3
View File
@@ -2,6 +2,7 @@ package handler
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
@@ -26,6 +27,8 @@ func RequestEmailLoginCode(c *gin.Context) {
return
}
if err != nil {
// 保留 service 返回的 provider 错误(含服务商名称与上游状态码),便于生产排障关联
log.Printf("[EMAIL-AUTH] 验证码发送失败: email=%s err=%v", request.Email, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "验证码暂时无法发送"})
return
}
+17 -2
View File
@@ -9,6 +9,9 @@ import (
"github.com/gouki/lunar-server/internal/service"
)
// 内容安全检测服务(微信 msgSecCheck),access_token 内部缓存
var securityService = service.NewContentSecurityService()
// GetWishTrees 获取许愿树列表
func GetWishTrees(c *gin.Context) {
wishService := service.NewWishService()
@@ -79,12 +82,12 @@ func CreateWish(c *gin.Context) {
return
}
// 检查内容长度
// 检查内容长度(按字符数,中文按 1 字计)
maxLength := 20
if req.Type == "paid" {
maxLength = 100
}
if len(req.Content) > maxLength {
if len([]rune(req.Content)) > maxLength {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "内容长度超过限制",
@@ -92,6 +95,18 @@ func CreateWish(c *gin.Context) {
return
}
// 内容安全检测(微信 msgSecCheck):UGC 内容发布前必须过检
userService := service.NewUserService()
if user, err := userService.GetUserByID(userID.(uint)); err == nil && user != nil {
if err := securityService.CheckText(user.OpenID, req.Content); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "内容未通过安全检测:" + err.Error(),
})
return
}
}
wishService := service.NewWishService()
wish := &model.Wish{
UserID: userID.(uint),
+13 -11
View File
@@ -3,17 +3,19 @@ package model
import "time"
type BaziChartRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ReportID string `gorm:"index;size:64" json:"reportId"`
UserID *uint `gorm:"index" json:"userId,omitempty"`
Name string `gorm:"size:64" json:"name"`
BirthDate string `gorm:"size:16" json:"birthDate"`
BirthTime string `gorm:"size:8" json:"birthTime"`
Gender string `gorm:"size:16" json:"gender"`
City string `gorm:"size:64" json:"city"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
ReportID string `gorm:"index;size:64" json:"reportId"`
UserID *uint `gorm:"index" json:"userId,omitempty"`
Name string `gorm:"size:64" json:"name"`
BirthDate string `gorm:"size:16" json:"birthDate"`
BirthTime string `gorm:"size:8" json:"birthTime"`
Gender string `gorm:"size:16" json:"gender"`
City string `gorm:"size:64" json:"city"`
ConsentVersion string `gorm:"size:32;not null;default:''" json:"consentVersion"`
ConsentAt *time.Time `json:"consentAt,omitempty"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportProduct struct {
+3 -1
View File
@@ -7,8 +7,10 @@ import (
"github.com/gouki/lunar-server/internal/model"
)
const ChartRecordConsentVersion = "chart-record-v1"
func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error {
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" {
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" || record.ConsentVersion != ChartRecordConsentVersion || record.ConsentAt == nil {
return errors.New("incomplete chart record")
}
return s.db.Create(record).Error
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/gouki/lunar-server/internal/config"
)
// ContentSecurityService 微信内容安全检测(msgSecCheck v2)。
// 小程序 UGC 内容(许愿等)发布前必须过检测,否则有下架风险。
type ContentSecurityService struct {
mu sync.Mutex
accessToken string
tokenExpire time.Time
}
func NewContentSecurityService() *ContentSecurityService {
return &ContentSecurityService{}
}
// 获取小程序 access_token,内存缓存到过期前 5 分钟
func (s *ContentSecurityService) getAccessToken(cfg *config.Config) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.accessToken != "" && time.Now().Before(s.tokenExpire) {
return s.accessToken, nil
}
query := url.Values{
"grant_type": {"client_credential"},
"appid": {cfg.Wechat.AppID},
"secret": {cfg.Wechat.AppSecret},
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.weixin.qq.com/cgi-bin/token?" + query.Encode())
if err != nil {
return "", fmt.Errorf("get access_token failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("read access_token response failed: %w", err)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse access_token response failed: %w", err)
}
if result.ErrCode != 0 || result.AccessToken == "" {
return "", fmt.Errorf("get access_token failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
s.accessToken = result.AccessToken
s.tokenExpire = time.Now().Add(time.Duration(result.ExpiresIn-300) * time.Second)
return s.accessToken, nil
}
// CheckText 校验文本是否安全。返回 nil 表示通过;
// 返回错误表示内容违规或检测失败(调用方应拒绝发布)。
//
// 未配置小程序凭证时(本地开发)放行并打日志;
// 生产环境若必须严格放行策略可再收紧。
func (s *ContentSecurityService) CheckText(openID, content string) error {
cfg := config.Load()
if cfg.Wechat.AppID == "" || cfg.Wechat.AppSecret == "" {
log.Printf("[content-security] 未配置小程序凭证,跳过检测")
return nil
}
token, err := s.getAccessToken(cfg)
if err != nil {
return err
}
reqBody, _ := json.Marshal(map[string]interface{}{
"version": 2,
"openid": openID,
"scene": 2, // 2=评论/留言类 UGC
"content": content,
})
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(
"https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+token,
"application/json",
bytes.NewReader(reqBody),
)
if err != nil {
return fmt.Errorf("msg_sec_check request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("read msg_sec_check response failed: %w", err)
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Result struct {
Suggest string `json:"suggest"`
Label int `json:"label"`
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse msg_sec_check response failed: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("msg_sec_check failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
switch result.Result.Suggest {
case "pass":
return nil
case "review":
// 人工复核档位:许愿类短文本直接拒绝,提示修改
return fmt.Errorf("内容需要审核,请修改后重试")
default:
return fmt.Errorf("内容不符合社区规范")
}
}
+5 -5
View File
@@ -36,17 +36,17 @@ export default {
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg">
<div class="p-5 border-b flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div><h2 class="text-lg font-medium">排盘记录</h2><p class="text-xs text-gray-500 mt-1">共 {{ total }} 次排盘,保存免费排盘快照,付费报告另行关联</p></div>
<div><h2 class="text-lg font-medium">排盘记录</h2><p class="text-xs text-gray-500 mt-1">共 {{ total }} 条用户主动同意保存的记录;付费报告快照另行关联</p></div>
<div class="flex gap-2"><input v-model="search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="报告编号 / 姓名 / 城市" /><button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button></div>
</div>
<div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">报告编号</th><th class="px-4 py-3 text-left text-xs text-gray-500">称呼</th><th class="px-4 py-3 text-left text-xs text-gray-500">出生信息</th><th class="px-4 py-3 text-left text-xs text-gray-500">城市</th><th class="px-4 py-3 text-left text-xs text-gray-500">记录时间</th></tr></thead>
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">报告编号</th><th class="px-4 py-3 text-left text-xs text-gray-500">称呼</th><th class="px-4 py-3 text-left text-xs text-gray-500">出生信息</th><th class="px-4 py-3 text-left text-xs text-gray-500">城市</th><th class="px-4 py-3 text-left text-xs text-gray-500">同意记录</th><th class="px-4 py-3 text-left text-xs text-gray-500">记录时间</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr v-if="loading"><td colspan="5" class="p-10 text-center text-sm text-gray-500">正在加载...</td></tr>
<tr v-else-if="records.length === 0"><td colspan="5" class="p-10 text-center text-sm text-gray-500">暂无排盘记录</td></tr>
<tr v-for="record in records" :key="record.id" class="hover:bg-gray-50"><td class="px-4 py-4 text-xs font-medium">{{ record.reportId }}</td><td class="px-4 py-4 text-sm">{{ record.name || '-' }}</td><td class="px-4 py-4 text-sm">{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}</td><td class="px-4 py-4 text-sm">{{ record.city }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</td></tr>
<tr v-if="loading"><td colspan="6" class="p-10 text-center text-sm text-gray-500">正在加载...</td></tr>
<tr v-else-if="records.length === 0"><td colspan="6" class="p-10 text-center text-sm text-gray-500">暂无排盘记录</td></tr>
<tr v-for="record in records" :key="record.id" class="hover:bg-gray-50"><td class="px-4 py-4 text-xs font-medium">{{ record.reportId }}</td><td class="px-4 py-4 text-sm">{{ record.name || '-' }}</td><td class="px-4 py-4 text-sm">{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}</td><td class="px-4 py-4 text-sm">{{ record.city }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ record.consentVersion || '迁移前记录(无同意凭证)' }}<br />{{ record.consentAt ? new Date(record.consentAt).toLocaleString('zh-CN', { hour12: false }) : '-' }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</td></tr>
</tbody>
</table>
</div>
+6
View File
@@ -7,6 +7,12 @@
- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。
- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。
### 排盘数据告知与同意
- 免费排盘默认只在当前页面展示;用户主动同意后才上传出生资料和排盘快照。
- 服务端保存同意版本与时间戳,拒绝缺少同意的写入,并对公开保存接口限流。
- 修正“本地计算保护隐私”的误导文案,并在付费下单前说明报告快照会保存到服务端。
### 排盘记录与免费层
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
+20 -14
View File
@@ -8,25 +8,30 @@
```mermaid
flowchart TD
A[填写出生日期、时间、性别、城市] --> B[本地计算真太阳时与八字]
A[填写出生日期、时间、性别、城市] --> B[在当前设备计算真太阳时与八字]
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
C --> D{是否解锁深度解读}
D -- 否 --> E[继续查看免费排盘]
D -- 是 --> F[QQ 邮箱 SMTP 验证码登录]
F --> G{选择支付渠道}
G --> H[Stripe: 4.99 USD]
G --> I[Binance Pay: 5 USDT]
H --> J[服务商签名 Webhook]
I --> J
J --> K[事务更新订单和报告权益]
K --> L[生成随机报告恢复码]
L --> M[保存不可变报告快照]
M --> N[用户凭购买邮箱和恢复码跨设备找回]
C --> D{是否同意保存本次排盘记录}
D -- 是 --> E[服务端保存所列资料与排盘快照]
D -- 否 --> F[只在当前页面查看,不上传免费排盘记录]
C --> G{是否解锁深度解读}
G -- 否 --> H[继续查看免费排盘]
G -- 是 --> I[QQ 邮箱 SMTP 验证码登录并确认下单数据保存说明]
I --> J{选择支付渠道}
J --> K[Stripe: 4.99 USD]
J --> L[Binance Pay: 5 USDT]
K --> M[服务商签名 Webhook]
L --> M
M --> N[事务更新订单和报告权益]
N --> O[生成随机报告恢复码]
O --> P[保存不可变报告快照]
P --> Q[用户凭购买邮箱和恢复码跨设备找回]
```
## 3. 免费排盘内容
每次排盘都会向服务端保存一份免费快照,记录出生参数、报告编号和计算结果版本,避免用户重复排盘后无法追踪历史。相同输入在同一算法版本下应得到相同结果;算法升级后保留旧快照,不覆盖历史记录。
八字运算在当前设备完成。用户默认不上传免费排盘资料;只有主动勾选保存选项后,姓名(如填写)、出生日期、出生时间、性别、城市和排盘快照才会发送到服务端,供授权后台人员查看。未勾选仍可免费计算,但服务端不会创建免费排盘记录。服务端验证同意版本并对公开保存接口限流。相同输入在同一算法版本下应得到相同结果;算法升级后保留已保存快照,不覆盖历史记录。
购买时会另行告知:创建订单会把报告快照和订单所需信息发送到服务端,用于生成、交付和恢复已购报告;该购买记录不依赖免费排盘保存选项。
免费层展示基础事实,不把基础数据故意藏到付费层:
@@ -151,6 +156,7 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用
- `report_snapshots`:购买时不可变报告快照。
- `payment_events`:Webhook 幂等记录。
- `email_login_codes`:一次性验证码。
- `bazi_chart_records`:排盘记录增加同意版本与同意时间字段;旧记录不回填为已同意。
- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。
新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。
+1 -1
View File
@@ -13,7 +13,7 @@
- Postmark 邮箱验证码登录。
- 支付 Webhook 验签与幂等处理。
- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。
- 每次排盘保存服务端历史记录,后台可查询排盘次数与出生参数。
- 免费排盘默认不上传;用户主动同意后,服务端保存出生资料、排盘快照及同意版本/时间,授权后台可查询记录。
- 已购报告历史列表、随机恢复码与购买时报告快照。
- 后台真实订单列表、排盘记录和收入统计。
+1 -1
View File
@@ -1,2 +1,2 @@
allowBuilds:
esbuild: set this to true or false
esbuild: true
+28 -12
View File
@@ -30,6 +30,7 @@ import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
import {
authEmailKey,
authTokenKey,
chartRecordConsentVersion,
checkReportAccess,
createCheckout,
isDemoPayment,
@@ -108,6 +109,8 @@ function App() {
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
const [report, setReport] = useState<ReportState | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [consentToSaveChart, setConsentToSaveChart] = useState(false);
const [chartSaveStatus, setChartSaveStatus] = useState('');
const [isUnlocked, setIsUnlocked] = useState(false);
const [showCheckout, setShowCheckout] = useState(false);
const [showRecovery, setShowRecovery] = useState(false);
@@ -137,6 +140,8 @@ function App() {
const calculate = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const shouldSaveChart = consentToSaveChart;
setConsentToSaveChart(false);
setIsCalculating(true);
const [year, month, day] = form.date.split('-').map(Number);
const [hour, minute] = form.time.split(':').map(Number);
@@ -161,15 +166,20 @@ function App() {
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
setReport(chartSnapshot);
void saveChartRecord({
reportId: id,
name: form.name,
birthDate: form.date,
birthTime: form.time,
gender: form.gender,
city: form.city,
snapshot: chartSnapshot,
}).catch(() => undefined);
setChartSaveStatus(shouldSaveChart ? '正在保存本次排盘记录…' : '本次未上传或保存排盘资料。');
if (shouldSaveChart) {
void saveChartRecord({
reportId: id,
name: form.name,
birthDate: form.date,
birthTime: form.time,
gender: form.gender,
city: form.city,
consentVersion: chartRecordConsentVersion,
snapshot: chartSnapshot,
}).then(() => setChartSaveStatus('本次排盘记录已保存至服务端,授权后台人员可查看。'))
.catch(() => setChartSaveStatus('排盘已完成,但服务端保存失败;本次结果仅保留在当前页面。'));
}
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
setIsUnlocked(localAccess);
const token = localStorage.getItem(authTokenKey);
@@ -203,7 +213,7 @@ function App() {
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
<div className="trust-row">
<span><ShieldCheck size={17} /> 本地计算保护隐私</span>
<span><ShieldCheck size={17} /> 资料保存由你选择</span>
<span><WalletCards size={17} /> 一次购买永久回看</span>
<span><HeartHandshake size={17} /> 无自动续费</span>
</div>
@@ -245,10 +255,15 @@ function App() {
<ChevronDown size={15} />
</label>
</div>
<label className="privacy-consent">
<input type="checkbox" checked={consentToSaveChart} onChange={event => setConsentToSaveChart(event.target.checked)} />
<span><strong>同意保存本次排盘资料(可选)</strong>姓名(如填写)、出生日期和时间、性别、城市及排盘结果会发送到服务端保存,授权后台人员可查看。未勾选仍可免费排盘,但不会保存本次记录。</span>
</label>
<button className="primary-action" type="submit" disabled={isCalculating}>
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
</button>
<p className="form-note"><Fingerprint size={14} /> 出生资料仅在当前设备参与计算,不会公开展示</p>
<p className="form-note"><Fingerprint size={14} /> 排盘在当前设备计算;勾选保存后才会上传所列资料。</p>
{chartSaveStatus && <p className="chart-save-status" aria-live="polite">{chartSaveStatus}</p>}
</form>
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
@@ -631,7 +646,8 @@ function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; s
</>
)}
{error && <p className="payment-error">{error}</p>}
<p className="checkout-note"><ShieldCheck /> 服务商签名回调确认到账 · 无自动续费</p>
<p className="checkout-note"><ShieldCheck />{isDemoPayment ? '演示支付不会向服务端创建真实订单。' : '下单后,报告快照和订单信息会发送到服务端保存,用于生成、交付和恢复已购报告。'}</p>
<p className="checkout-note">服务商签名回调确认到账 · 无自动续费</p>
</div>
</div>
);
+2
View File
@@ -21,6 +21,7 @@ interface ApiPayload<T> {
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
export const chartRecordConsentVersion = 'chart-record-v1';
export const authTokenKey = 'lunar-commerce-token';
export const authEmailKey = 'lunar-commerce-email';
@@ -45,6 +46,7 @@ export async function saveChartRecord(input: {
birthTime: string;
gender: string;
city: string;
consentVersion: string;
snapshot: unknown;
}): Promise<void> {
await apiRequest<never>('/api/commerce/charts', {
+4
View File
@@ -187,6 +187,10 @@ header nav a:hover { color: #fff; }
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
.privacy-consent { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 18px; color: #77786f; font-size: 10px; line-height: 1.7; cursor: pointer; }
.privacy-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--vermilion); }
.privacy-consent strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; }
.chart-save-status { margin: 9px 0 0; color: var(--ink-soft); text-align: center; font-size: 10px; line-height: 1.6; }
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.vite
*.tsbuildinfo
*.local
.DS_Store
*.log
coverage
.swc
+72
View File
@@ -0,0 +1,72 @@
# Lunar Calendar App (万年历)
Chinese almanac/perpetual calendar app with Bazi calculation, daily fortune, divination tools, and beautiful UI.
## Documentation
Detailed docs live in `docs/` — read them before starting any task:
- **docs/ARCHITECTURE.md** — tech stack, layered design, API surface, routes, stores
- **docs/REQUIREMENTS.md** — full feature inventory with completion status
- **docs/PROGRESS.md** — milestones, current state, prioritized backlog
- **docs/BUGS.md** — known bugs, algorithmic approximations, dead code
- **docs/CHANGELOG.md** — version history
## Tech Stack
- **Monorepo**: pnpm workspaces
- **Core**: TypeScript + tyme4ts (calendar engine)
- **Web**: React 19 + Vite + TailwindCSS v4 + Framer Motion + Zustand + React Router v7
- **PWA**: vite-plugin-pwa (configured and built — autoUpdate + Workbox)
## Project Structure
```
lunar/
├── docs/ # Architecture / Requirements / Progress / Bugs / Changelog
├── packages/
│ ├── core/ # @lunar/core — Pure TS, no UI deps
│ │ └── src/
│ │ ├── types/ # calendar.ts, bazi.ts, almanac.ts, fortune.ts
│ │ ├── transformers/ # tyme4ts → plain objects (day.ts, bazi.ts, almanac.ts)
│ │ └── calculators/ # dailyMatch.ts, relationship.ts, elementStrength.ts, plumBlossom.ts, boneWeight.ts
│ └── web/ # @lunar/web — React app
│ └── src/
│ ├── stores/ # Zustand: calendar, user, settings, ui, bookmarks
│ ├── hooks/ # useCalendar, useDayDetail, useBazi, useDailyFortune
│ ├── components/ # calendar/, layout/, ui/
│ └── pages/ # Home, Calendar, DayDetail, Bazi, DailyFortune, Settings, Divination, SolarTerms
```
## Key Architecture Principles
1. **tyme4ts objects never enter React** — all transformed to plain JS objects in @lunar/core
2. **@lunar/core has zero UI deps** — works in any JS runtime (web, Node, RN, mini-program)
3. **Mobile-first responsive** — phone first, desktop sidebar layout
4. **Dark mode via CSS custom properties** — theme toggle with system preference detection
## Commands
- `pnpm dev` — Start Vite dev server (port 4258)
- `pnpm build` — Build core (ESM+CJS) + web
- `pnpm --filter @lunar/core build` — Build core only (tsup)
- `pnpm --filter @lunar/web dev` — Start web dev server
- `pnpm preview` — Preview production build
- `pnpm test` — Run core unit tests (vitest)
- `pnpm lint` — Run ESLint on all packages
## Core API (from @lunar/core)
- `getDayInfo(year, month, day)` → DayInfo
- `getTodayInfo()` → DayInfo
- `getMonthCalendar(year, month, weekStart?)` → DayInfo[][]
- `getAlmanacInfo(year, month, day)` → AlmanacInfo
- `birthInfoToBazi(params)` → BaziFullResult
- `calculateDailyFortune(userBazi, date)` → DailyFortuneResult
- `analyzeElementBalance(bazi)` → ElementProfile
- `calculatePlumBlossom(y, m, d, hour?)` → PlumBlossomResult
- `calculateBoneWeight(...)` → BoneWeightResult
- `getBranchRelationship(a, b)` / `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite`
- `getBuddhistFestival(lunarMonth, lunarDay)` → string | null
- `analyzeShensha(bazi)` → ShenshaInfo[](22 个常见神煞)
- `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` → FortuneLuck(大运/流年 vs 日主生克冲合)
- `getYearMonths(year)` → YearMonthInfo[](按节气月)
## Known Issues
See **docs/BUGS.md** for the current list. Resolved: core CJS export, week-start setting, PWA manifest metadata, tests (50), lint, 称骨 data tables, 八字流派 switching, shensha, 大运流年 explorer. Remaining: simplified 互卦 algorithm, dead code cleanup.
+72
View File
@@ -0,0 +1,72 @@
# Lunar Calendar App (万年历)
Chinese almanac/perpetual calendar app with Bazi calculation, daily fortune, divination tools, and beautiful UI.
## Documentation
Detailed docs live in `docs/` — read them before starting any task:
- **docs/ARCHITECTURE.md** — tech stack, layered design, API surface, routes, stores
- **docs/REQUIREMENTS.md** — full feature inventory with completion status
- **docs/PROGRESS.md** — milestones, current state, prioritized backlog
- **docs/BUGS.md** — known bugs, algorithmic approximations, dead code
- **docs/CHANGELOG.md** — version history
## Tech Stack
- **Monorepo**: pnpm workspaces
- **Core**: TypeScript + tyme4ts (calendar engine)
- **Web**: React 19 + Vite + TailwindCSS v4 + Framer Motion + Zustand + React Router v7
- **PWA**: vite-plugin-pwa (configured and built — autoUpdate + Workbox)
## Project Structure
```
lunar/
├── docs/ # Architecture / Requirements / Progress / Bugs / Changelog
├── packages/
│ ├── core/ # @lunar/core — Pure TS, no UI deps
│ │ └── src/
│ │ ├── types/ # calendar.ts, bazi.ts, almanac.ts, fortune.ts
│ │ ├── transformers/ # tyme4ts → plain objects (day.ts, bazi.ts, almanac.ts)
│ │ └── calculators/ # dailyMatch.ts, relationship.ts, elementStrength.ts, plumBlossom.ts, boneWeight.ts
│ └── web/ # @lunar/web — React app
│ └── src/
│ ├── stores/ # Zustand: calendar, user, settings, ui, bookmarks
│ ├── hooks/ # useCalendar, useDayDetail, useBazi, useDailyFortune
│ ├── components/ # calendar/, layout/, ui/
│ └── pages/ # Home, Calendar, DayDetail, Bazi, DailyFortune, Settings, Divination, SolarTerms
```
## Key Architecture Principles
1. **tyme4ts objects never enter React** — all transformed to plain JS objects in @lunar/core
2. **@lunar/core has zero UI deps** — works in any JS runtime (web, Node, RN, mini-program)
3. **Mobile-first responsive** — phone first, desktop sidebar layout
4. **Dark mode via CSS custom properties** — theme toggle with system preference detection
## Commands
- `pnpm dev` — Start Vite dev server (port 4258)
- `pnpm build` — Build core (ESM+CJS) + web
- `pnpm --filter @lunar/core build` — Build core only (tsup)
- `pnpm --filter @lunar/web dev` — Start web dev server
- `pnpm preview` — Preview production build
- `pnpm test` — Run core unit tests (vitest)
- `pnpm lint` — Run ESLint on all packages
## Core API (from @lunar/core)
- `getDayInfo(year, month, day)` → DayInfo
- `getTodayInfo()` → DayInfo
- `getMonthCalendar(year, month, weekStart?)` → DayInfo[][]
- `getAlmanacInfo(year, month, day)` → AlmanacInfo
- `birthInfoToBazi(params)` → BaziFullResult
- `calculateDailyFortune(userBazi, date)` → DailyFortuneResult
- `analyzeElementBalance(bazi)` → ElementProfile
- `calculatePlumBlossom(y, m, d, hour?)` → PlumBlossomResult
- `calculateBoneWeight(...)` → BoneWeightResult
- `getBranchRelationship(a, b)` / `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite`
- `getBuddhistFestival(lunarMonth, lunarDay)` → string | null
- `analyzeShensha(bazi)` → ShenshaInfo[](22 个常见神煞)
- `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` → FortuneLuck(大运/流年 vs 日主生克冲合)
- `getYearMonths(year)` → YearMonthInfo[](按节气月)
## Known Issues
See **docs/BUGS.md** for the current list. Resolved: core CJS export, week-start setting, PWA manifest metadata, tests (50), lint, 称骨 data tables, 八字流派 switching, shensha, 大运流年 explorer. Remaining: simplified 互卦 algorithm, dead code cleanup.
+56
View File
@@ -0,0 +1,56 @@
# 万年历(Lunar Calendar App)
中国农历 / 黄历应用:农历转换、黄历宜忌、八字排盘(大运流年流月流日)、每日运势、神煞、梅花易数、称骨算命、节气、佛教节日。移动端优先,支持 PWA 离线安装。
## 技术栈
- **Monorepo**:pnpm workspaces
- **@lunar/core**:TypeScript + [tyme4ts](https://github.com/6tail/tyme4ts)(历法引擎),零 UI 依赖,ESM+CJS 双产物
- **@lunar/web**:React 19 + Vite 6 + TailwindCSS v4 + Zustand 5 + React Router v7 + Framer Motion + vite-plugin-pwa
## 快速开始
```bash
pnpm install
pnpm dev # http://localhost:4258
pnpm test # core 单元测试(vitest,50 例)
pnpm lint # ESLint
pnpm build # 构建 core + web(含 PWA)
pnpm preview # 预览构建产物
```
> 需要 Node ≥ 18、pnpm ≥ 9。
## 文档
详细文档见 `docs/`:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) — 架构、API、路由、状态管理
- [REQUIREMENTS.md](docs/REQUIREMENTS.md) — 功能点清单与完成状态
- [PROGRESS.md](docs/PROGRESS.md) — 里程碑与待办
- [BUGS.md](docs/BUGS.md) — 已知 BUG、技术债、死代码
- [CHANGELOG.md](docs/CHANGELOG.md) — 变更日志
- [RETROSPECT.md](docs/RETROSPECT.md) — 会话回顾(做了什么/没做什么/疑问)
## 目录结构
```
lunar/
├── docs/ # 项目文档
├── packages/
│ ├── core/ # @lunar/core —— 纯 TS 计算引擎
│ └── web/ # @lunar/web —— React 应用
├── AGENTS.md / CLAUDE.md # 开发代理指引
└── eslint.config.js # ESLint flat config
```
## 核心能力
| 模块 | 说明 |
|---|---|
| 历法 | 公历/农历/干支/节气/季节/儒略日/佛历/伊斯兰历/月相 |
| 黄历 | 宜忌、值神(日/时)、二十八宿、彭祖百忌、胎神、冲煞 |
| 八字 | 四柱、十神、藏干、十二长生、大运、流年、神煞、流派切换 |
| 运势 | 每日运势、流年/流月/流日 vs 日主生克冲合 |
| 占卜 | 梅花易数(64 卦)、袁天罡称骨 |
| 其他 | 佛教节日、节气列表、收藏、PWA |
+162
View File
@@ -0,0 +1,162 @@
# 架构文档(Architecture)
> 最后更新:2026-08-03
## 1. 项目概述
万年历(Lunar Calendar App):中国农历/黄历应用,提供农历转换、黄历宜忌、八字排盘、每日运势、梅花易数、称骨算命、节气等命理功能,移动端优先,支持 PWA 离线安装。
## 2. 技术栈
| 层 | 技术 |
|---|---|
| Monorepo | pnpm workspaces(`packages/*`) |
| 核心引擎 | TypeScript + [tyme4ts](https://github.com/6tail/tyme4ts) ^1.5.1(历法/干支计算) |
| 构建 | tsup(core)、Vite 6 + tsc(web) |
| UI | React 19、React Router v7、Zustand 5、TailwindCSS v4(CSS-first)、Framer Motion、lucide-react |
| PWA | vite-plugin-pwa 0.21(Workbox,已启用) |
## 3. 目录结构
```
lunar/
├── docs/ # 项目文档(本目录,含 RETROSPECT 会话回顾)
├── AGENTS.md # 开发代理指引
├── package.json # workspace 聚合脚本
├── pnpm-workspace.yaml # 工作区定义
├── tsconfig.base.json # 共享 TS 配置
└── packages/
├── core/ # @lunar/core —— 纯 TS 计算引擎,零 UI 依赖
│ ├── tsup.config.ts
│ └── src/
│ ├── index.ts # 公共 API 出口(barrel)
│ ├── types/ # calendar.ts / bazi.ts / almanac.ts / fortune.ts
│ ├── transformers/ # day.ts / bazi.ts / almanac.ts / yearMonths.ts(tyme4ts → 纯对象)
│ └── calculators/ # dailyMatch / relationship / elementStrength / plumBlossom / boneWeight / buddhistDates / shensha / fortuneLuck
└── web/ # @lunar/web —— React 应用
├── vite.config.ts # Vite + Tailwind v4 + PWA
├── index.html
└── src/
├── App.tsx # 路由 + 懒加载 + 页面过渡 + ErrorBoundary
├── main.tsx
├── styles/globals.css # 设计令牌(亮/暗主题)
├── lib/utils.ts # cn、日期格式化等工具
├── stores/ # calendar / user / settings / ui / bookmarks(Zustand)
├── hooks/ # useCalendar / useDayDetail / useBazi / useDailyFortune
├── components/
│ ├── calendar/ # CalendarGrid / CalendarCell / MonthYearPicker / WeekDayBar
│ ├── layout/ # AppShell / Header / BottomNav
│ ├── ui/ # Badge / Button / Card / AnimatedPanel / ErrorBoundary / Skeleton
│ ├── day-detail/ bazi/ daily-fortune/(页面内联实现,目录内暂无独立组件)
└── pages/ # 8 个页面(见路由表)
```
## 4. 分层设计
```
┌───────────────────────────────┐
│ @lunar/web (React 页面层) │
│ pages → hooks → stores │
├───────────────────────────────┤
│ @lunar/core (纯计算引擎) │
│ index.ts │
│ ├─ transformers tyme4ts → │
│ │ 纯 JS 对象 │
│ ├─ calculators 业务算法 │
│ └─ types 类型定义 │
├───────────────────────────────┤
│ tyme4ts (历法计算源) │
└───────────────────────────────┘
```
### 4.1 核心原则
1. **tyme4ts 对象永不进入 React**:所有历法对象在 `@lunar/core` 内转换为普通 JS 对象(`DayInfo`、`AlmanacInfo`、`BaziFullResult` 等),React 层只消费纯数据。
2. **@lunar/core 零 UI 依赖**:仅依赖 tyme4ts,可在任意 JS 运行时运行(web / Node / RN / 小程序)。
3. **移动端优先**:手机优先布局,桌面端自适应。
4. **暗色模式**:CSS 自定义属性 + `.dark` class,跟随系统偏好 + 手动切换。
### 4.2 数据流示例
```
SolarDay (tyme4ts)
→ solarDayToDayInfo() # DayInfo
→ solarDayToAlmanacInfo() # AlmanacInfo(含 12 时辰)
→ birthInfoToBazi() # BaziFullResult(四柱/十神/大运…)
→ calculateDailyFortune() # DailyFortuneResult(每日运势)
```
## 5. @lunar/core API 一览
### 类型
`DayInfo`、`AlmanacInfo`、`HourAlmanac`、`PillarInfo`、`HideStemInfo`、`EightCharInfo`、`DecadeFortuneInfo`、`FortuneInfo`、`ChildLimitInfo`、`BaziFullResult`、`PillarRelationship`、`DailyFortuneResult`、`BirthParams`、`BranchRelationship`、`ElementProfile`、`TrigramInfo`、`HexagramInfo`、`PlumBlossomResult`、`BoneWeightResult`
### 函数
| 函数 | 说明 |
|---|---|
| `solarDayToDayInfo(solarDay)` | SolarDay → DayInfo(含季节/节气进度/儒略日/佛历/伊斯兰历/佛教节日) |
| `getDayInfo(y, m, d)` / `getTodayInfo()` | 获取某日/今日信息 |
| `getMonthCalendar(y, m, weekStart?)` | 月历二维数组(周 × 天),可指定周起始 |
| `solarDayToAlmanacInfo(solarDay)` | SolarDay → AlmanacInfo |
| `getAlmanacInfo(y, m, d)` | 获取黄历信息(宜忌/值神/冲煞/时辰) |
| `birthInfoToBazi(params)` | 出生信息 → 完整八字排盘(`ziSect` 流派参数:晚子时换日) |
| `getYearMonths(year)` | 按节气月返回某年 12 个流月 |
| `getBranchRelationship(a, b)` | 地支六合/三合/六冲/六害/相刑 |
| `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite` | 干支关系判断 |
| `calculateDailyFortune(userBazi, date)` | 用户八字 × 日期 → 每日运势评分 |
| `analyzeElementBalance(bazi)` | 五行力量分析 |
| `calculatePlumBlossom(y, m, d, h?)` | 梅花易数起卦 |
| `calculateBoneWeight(...)` | 袁天罡称骨算命 |
| `getBuddhistFestival(lunarMonth, lunarDay)` | 农历佛教节日 |
| `analyzeShensha(bazi)` | 22 个常见神煞 |
| `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` | 大运/流年/流月/流日 vs 日主生克冲合 |
## 6. 前端状态管理(Zustand)
| Store | 持久化 Key | 职责 | 备注 |
|---|---|---|---|
| `calendar` | — | 视图日期 / 选中日期 / 周起始 | `weekStart`、`clearSelection` 当前未被使用 |
| `user` | `lunar-user-profiles` | 出生档案(最多 3 个)、active 档案、八字结果 | |
| `settings` | `lunar-settings` | 主题、周起始、显示开关、八字来源 | `showLunar` 等 4 个字段未被消费 |
| `ui` | — | 侧栏 / 移动端 / 底部面板 | 多数 action 未被消费 |
| `bookmarks` | `lunar-bookmarks` | 日期收藏(标记在日历格上) | `getByDate`/`getByLunarDate` 未使用 |
## 7. 路由(React Router v7,全部懒加载)
| 路径 | 页面 | 底部导航 |
|---|---|---|
| `/` | HomePage(今日概览) | ✔ |
| `/calendar` | CalendarPage(月/周视图) | ✔ |
| `/calendar/:date` | DayDetailPage(日详情) | — |
| `/bazi` | BaziPage(八字排盘) | ✔ |
| `/daily-fortune` | DailyFortunePage(每日运势) | ✔ |
| `/settings` | SettingsPage(设置) | ✔ |
| `/divination` | DivinationPage(梅花易数 + 称骨) | 首页快捷入口 |
| `/solar-terms` | SolarTermsPage(节气) | 首页快捷入口 |
| `*` | 重定向 `/` | — |
## 8. PWA(已启用)
- `vite-plugin-pwa`:autoUpdate 模式 + Workbox 预缓存 + google-fonts 运行时缓存
- 构建产物:`manifest.webmanifest`、`sw.js`、`registerSW.js`
- manifest 已与 index.html 对齐(`lang: zh-CN`、`theme_color: #FFFBF5`)
## 9. 构建与命令
| 命令 | 说明 |
|---|---|
| `pnpm dev` | 启动 web dev(端口 4258) |
| `pnpm build` | 构建 core → web |
| `pnpm --filter @lunar/core build` | 仅构建 core(tsup,ESM+CJS) |
| `pnpm --filter @lunar/web build` | 仅构建 web(tsc -b && vite build) |
| `pnpm preview` | 预览构建产物 |
| `pnpm test` | core 单元测试(vitest,50 例) |
| `pnpm lint` | ESLint(flat config + typescript-eslint) |
| `pnpm clean` | 清理 dist |
## 10. 已知架构问题(详见 BUGS.md)
- 梅花易数互卦为简化实现(上下卦互换,非真·互卦 2-4/3-5 爻法)
- 每日运势当日八字固定取午时;称骨极端总重仍取"最近值"
- 若干死代码(未使用的 hooks / 组件 / store action,见 BUGS.md 清单)
+64
View File
@@ -0,0 +1,64 @@
# BUG 记录(Known Issues)
> 最后更新:2026-08-02
> 严重度:🔴 高(影响功能/构建) · 🟠 中 · 🟡 低
> 状态:⬜ 待修复 · 🔧 修复中 · ✅ 已修复
## 0. 已修复汇总(2026-08-02)
- BUG-01 core CJS 导出 · BUG-02 周起始设置 · BUG-05 lint · BUG-07 版本号 · BUG-08 死分支
- BUG-03 PWA manifest 与 index.html 对齐(lang=zh-CN、theme_color 统一)
- BUG-09 日历页"回到今天"按钮永不显示(条件恒为 false)
- BUG-10 日详情页时辰列表重复 React key(早子/晚子均为"子",改用 ganzhi 作 key)
- TECH-03 更正:梅花卦库实测 **64/64 完整**(原 review 报告"缺 8 卦"不属实),已添加测试锁定
- TECH-06 以 `solarAdjusted` 类型安全字段替代 `(as any)._solarAdjusted`
- TECH-04 称骨数据表整体修正(年表主流版本 + 日表初五 + 歌诀 21~72 补全)
- TECH-05 占卜页称骨年索引改用出生年干支
- 新增单元测试 50 个(vitest)与 ESLint 配置
- 功能深化:八字流派/出生地全国化/海外时区/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日(见 CHANGELOG)
## 1. 已知 BUG
| ID | 严重度 | 状态 | 位置 | 描述 |
|---|---|---|---|---|
| BUG-01 | 🟠 | ✅ | `packages/core` | ~~exports.require 指向不存在的 dist/index.cjs~~ → tsup 改为 `format: ['esm','cjs']`,`dist/index.cjs` 已生成并通过 `require()` 验证 |
| BUG-02 | 🟠 | ✅ | `useCalendar` / `CalendarGrid` / `WeekDayBar` / `CalendarPage` | ~~周一开始设置无效~~ → `settings.weekStartDay` 已贯通至 `getMonthCalendar(weekStart)` 与表头渲染 |
| BUG-03 | 🟡 | ✅ | `vite.config.ts` / `index.html` | ~~PWA manifest lang=en、theme_color 不一致~~ → manifest 增加 `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`,与 index.html 一致 |
| BUG-04 | 🟡 | ⬜ | `HomePage`(InfoCard) | `highlight` prop 传为布尔值,但渲染时当作 `border-primary/30` 样式类处理,语义不符(需确认意图) |
| BUG-05 | 🟡 | ✅ | 根 `package.json` | ~~pnpm lint 失败~~ → 已配置 ESLint 9 flat config + typescript-eslint,两个包均通过 |
| BUG-06 | 🟡 | ⬜ | `pnpm-workspace.yaml` | `allowBuilds` 不是 pnpm 标准字段(应为 `onlyBuiltDependencies`),疑似无效配置 |
| BUG-07 | 🟡 | ✅ | `SettingsPage` 关于区 | ~~版本号 v0.2~~ → 与 package.json 统一为 v0.1.0 |
| BUG-08 | 🟡 | ✅ | `transformers/day.ts` | ~~getMonthCalendar 死分支~~ → 已移除 if/else 相同调用 |
| BUG-09 | 🟡 | ✅ | `CalendarPage.tsx` | ~~"回到今天"按钮永不显示~~ → 条件误用 `todaySummary.di.isToday`(恒为 true),改为基于 `viewDate` 判断是否正在查看今天;已在浏览器实测 |
| BUG-10 | 🟡 | ✅ | `DayDetailPage.tsx` | ~~时辰列表重复 React key~~ → `getHours()` 返回 13 个时辰(早子 00:00 / 晚子 23:00 均为地支"子"),原用 `h.branch` 作 key 冲突,改为唯一的 `h.ganzhi`;已在浏览器实测无警告 |
## 2. 算法近似 / 技术债
> 非紧急,但应在后续迭代中改善。
| ID | 位置 | 说明 |
|---|---|---|
| TECH-01 | `calculators/dailyMatch.ts` | 当日八字固定取午时(`getHours()[6]`),无法体现时辰差异 |
| TECH-02 | `calculators/plumBlossom.ts` | 互卦为简化实现(上下卦互换),非真·互卦(2-4/3-5 爻法) |
| TECH-03 | `calculators/plumBlossom.ts` | ✅ 已核实:数据集 64/64 完整,`plumBlossom.test.ts` 含完整性校验用例 |
| TECH-04 | `calculators/boneWeight.ts` | ✅ 已修复:歌诀补全 2两1钱~7两2钱(原缺 5两8钱/5两9钱/6两1钱/6两3钱/6两5钱/6两7钱/6两9钱/7两1钱,此前这些总重会错误地取"最近值");年表已按主流版本整体修正(原混用两套网络变体错约 40 处),日表修正初五重量 |
| TECH-05 | `DivinationPage` | ✅ 已修复:称骨年索引改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似 |
| TECH-06 | `types/bazi.ts` | ✅ 已修复:`BaziFullResult` 新增 `solarAdjusted?: boolean`,替代 `(as any)` 补丁 |
| TECH-07 | `calculators/elementStrength.ts` | 五行推断按天干名字映射而非复用 tyme4ts 元素定义(可接受但存在重复逻辑) |
| TECH-08 | `lib/utils.ts` | `getChineseDayName` / `getChineseMonthName` 未使用 |
## 3. 死代码清单
| 位置 | 内容 | 建议 |
|---|---|---|
| `hooks/useDayDetail.ts` | 从未被 import | 删除或接入 DayDetailPage |
| `hooks/useBazi.ts` | 从未被 import(本轮仅修复了其 lint 错误) | 删除或接入 BaziPage |
| `components/ui/AnimatedPanel.tsx` | 从未使用 | 删除或接入日详情弹层 |
| `components/ui/Card.tsx` `CardHeader` | 未使用 | 删除导出 |
| `components/ui/Skeleton.tsx` `CalendarSkeleton`/`BaziSkeleton` | 未使用 | 删除 |
| `stores/ui.ts` | `toggleSideNav`/`closeSideNav`/`setActiveTab`/`openDayDetail`/`closeDayDetail` 及 `activeTab`/`showDayDetail` 未被消费 | 精简或接入 |
| `stores/settings.ts` | `showLunar`/`showSolarTerm`/`showHoliday`/`eightCharProvider` 及对应 toggle 未被读取 | 接入设置页或删除 |
| `stores/calendar.ts` | `weekStart`/`setWeekStart`/`clearSelection` 未使用(周起始现直接读 settings store) | 删除 |
| `stores/bookmarks.ts` | `getByDate`/`getByLunarDate` 未使用 | 配合农历收藏规划 |
| `lib/utils.ts` | `getChineseDayName`/`getChineseMonthName` | 删除 |
| web 依赖 | `tailwind-variants` 从未 import | 移除依赖 |
+80
View File
@@ -0,0 +1,80 @@
# 变更日志(Changelog)
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。版本号遵循 SemVer。
## [Unreleased]
### 文档
- 新增 `docs/` 文档体系:架构(ARCHITECTURE)、需求(REQUIREMENTS)、进度(PROGRESS)、BUG(BUGS)、变更日志(CHANGELOG)
- 更新 `AGENTS.md` / `CLAUDE.md`:补全新页面/Store/API、修正 PWA 状态、指向文档目录
### 功能
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect` 参数,设置页新增"八字流派"选项(晚子时算次日 23点换日 / 晚子时算当日 0点换日),BaziPage 排盘实时生效;浏览器实测两种流派排出不同日柱/时柱
- **出生时间选择器**:BaziPage 出生时间改为 时/分 下拉选择器(替代不对称的 −/+ 步进),时辰格可点击快速设置时辰
- **出生地全国化 + 海外时区**:出生地列出全国 34 个省级城市(含省会经度真太阳时校正),新增"海外"模式按时区(UTC-12~+14,含半小时时区)将当地出生时间换算为北京时间(UTC+8)排盘;换算与真太阳时校正均按日期运算处理,支持跨日回退(浏览器实测:UTC-5 23:30 → 次日北京 12:30;乌鲁木齐 00:30 真太阳 → 前日 22:20)
- **真太阳时校正开关**:设置页新增"真太阳时校正"开关(默认开启);关闭后中国出生按北京时间直接排盘,海外时区换算不受影响(浏览器实测:关闭时乌鲁木齐 00:30 排日柱己酉,开启时排戊申)
- **宜忌完整显示**:引擎本就返回完整黄历宜忌(如 8/3 共 22 条宜),此前首页 `slice(0,6)` 截断导致显得比别的黄历站少;已放开并标注总数
- **历法信息增强**:DayInfo 新增季节、所处节气/第几天/距下节气天数、儒略日、佛历年(公历+543)、伊斯兰历日期(tyme4ts 原生支持 Hijri),日详情页新增"历法信息"卡片
- **佛教节日**:tyme4ts 无佛教日期支持,新增 `getBuddhistFestival`(农历 21 个重大佛教节日表),日详情页显示 🪷 徽章
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞:天乙/天厨/文昌贵人、禄神、羊刃、金舆、天德/月德贵人、桃花、驿马、华盖、劫煞、亡神、将星、红鸾、天喜、孤辰、寡宿、天罗、地网、魁罡、阴差阳错),八字页新增"神煞"卡片;查法采用主流排盘工具版本(神煞版本差异已在文档注明)
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(任意干支 vs 日主:十神、五行生克 生我/我生/克我/我克/比和、天干合冲、地支六合三合冲害刑、吉凶分级);八字页"起运·大运"改为全量 10 个大运并附生克冲合,新增"流年(小运)"10 条卡片
- **今日流年·流月·流日**:八字页新增卡片,用今日年/月/日干支 vs 日主展示生克冲合
- **大运·流年·流月·流日交互下钻**:core 新增 `getYearMonths(year)`(按节气月返回 12 个流月);八字页改为交互浏览器——点选大运 → 流年(按年度切换)→ 流月 12 chips → 该月每日流日列表(带吉凶点与十神),点击日期跳转日详情页;默认定位到当前年份/月份
### 修复
- **称骨算命数据表整体修正**:年份表改为主流通行版本(原混用两套网络变体错约 40 处);日表修正初五(1两6钱);歌诀补全 2两1钱~7两2钱全部 52 条(原缺 5两8钱~7两1钱共 8 条,此前这些总重错误取"最近值")
- **占卜页称骨年索引**:改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似
- **BUG-01**:core 包改为同时输出 ESM + CJS,`exports.require` 指向的 `dist/index.cjs` 现在真实存在并通过 `require()` 验证
- **BUG-02**:周起始设置生效 —— `settings.weekStartDay` 贯通 `useCalendar` → `getMonthCalendar(weekStart)` → `WeekDayBar` 表头渲染
- **BUG-03**:PWA manifest 对齐 —— `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`(与 index.html 一致)
- **BUG-05**:配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处代码问题)
- **BUG-07**:设置页版本号统一为 0.1.0
- **BUG-08**:移除 `getMonthCalendar` 中的死分支
- **BUG-09**:日历页"回到今天"浮动按钮恢复正常 —— 原条件 `todaySummary.di.isToday` 恒为 true 导致按钮永不显示,改为基于 `viewDate` 判断(浏览器实测:非本月可见、点击返回本月)
- **BUG-10**:日详情页时辰列表重复 key 修复 —— 早子/晚子地支均为"子",key 改用唯一的干支(`h.ganzhi`)
- **TECH-06**:`BaziFullResult` 新增 `solarAdjusted?: boolean` 字段,替代 `(as any)._solarAdjusted` 非类型安全补丁
### 工程
- 为 @lunar/core 引入 vitest 单元测试(7 个测试文件 / 29 个用例),覆盖历法转换、八字排盘、每日运势、梅花易数(含 64 卦完整性校验)、称骨、干支关系、五行分析
- 根与子包新增 `test` / `lint` 脚本;根 package.json 标记 `"type": "module"`
- 核实梅花卦库为 64/64 完整(此前 review 报告"缺 8 卦"不属实),并以测试固化
### 已知问题(见 docs/BUGS.md)
- 梅花易数互卦仍为简化实现(上下卦互换)
- 每日运势当日八字固定取午时;称骨年索引用日干支近似
- PWA manifest `lang`/`theme_color` 不一致;死代码待清理
## [0.1.0] - 2026-06-07
首个可用版本(monorepo 初始化于 2026-06-06)。
### 新增 — @lunar/core
- 历法转换:`getDayInfo` / `getTodayInfo` / `getMonthCalendar` / `solarDayToDayInfo`
- 黄历:`getAlmanacInfo` / `solarDayToAlmanacInfo`(含 12 时辰逐时黄历)
- 八字:`birthInfoToBazi`(四柱、十神、藏干、十二长生、胎元胎息、命宫身宫、空亡、起运、10 大运、10 流年)
- 干支关系:`getBranchRelationship` / `getTenStarRelationship` / `checkStemCombine` / `checkStemOpposite`
- 五行分析:`analyzeElementBalance`
- 每日运势:`calculateDailyFortune`(四柱打分、宜忌、幸运色/数/方位、分项得分)
- 占卜:`calculatePlumBlossom`(梅花易数,56/64 卦)、`calculateBoneWeight`(袁天罡称骨)
### 新增 — @lunar/web
- 路由与骨架:8 页面懒加载、页面过渡动画、ErrorBoundary、Suspense 骨架屏
- 首页:今日概览、宜忌、运势预览、快捷入口
- 日历:月/周视图、年/月切换、收藏星标、运势点、滑动切月、日详情页(四柱/黄历/12 时辰/收藏)
- 八字排盘页:档案管理(最多 3 个,localStorage 持久化)、排盘全览、称骨
- 每日运势页:日期导航、评分与建议
- 占卜页:梅花易数今日卦象、称骨
- 节气页:全年 24 节气
- 设置页:主题切换(亮/暗/跟随系统)、周起始、阴阳历转换、今日干支
- 状态管理:Zustand 5 个 store(calendar / user / settings / ui / bookmarks)
- PWA:vite-plugin-pwa 集成(autoUpdate + Workbox 预缓存)
- 设计系统:Tailwind v4 设计令牌(亮/暗主题)、Badge / Button / Card / Skeleton
### 已知问题(见 docs/BUGS.md)
- core 包 require 导出指向不存在的 `dist/index.cjs`
- 周起始设置未生效
- 梅花易数互卦简化、卦库缺 8 卦;称骨年索引用日干支近似
- 无自动化测试、无 lint
+49
View File
@@ -0,0 +1,49 @@
# 开发进度(Progress)
> 最后更新:2026-08-03
## 1. 里程碑
| 里程碑 | 时间 | 内容 | 状态 |
|---|---|---|---|
| M0 项目初始化 | 2026-06-06 | monorepo 脚手架、tsconfig、workspace、核心类型定义 | ✅ |
| M1 核心引擎 | 2026-06-06 ~ 06-07 | transformers(day/almanac/bazi)、calculators(relationship/elementStrength) | ✅ |
| M2 高级算法 | 2026-06-07 | dailyMatch(每日运势)、plumBlossom(梅花)、boneWeight(称骨) | ✅ |
| M3 前端页面 | 2026-06-07 | 8 个页面、5 个 store、路由、布局、组件库 | ✅ |
| M4 构建与 PWA | 2026-06-07 | Vite 构建、tsup、PWA 集成(Workbox 缓存) | ✅ |
| M5 文档化 | 2026-08-02 | 架构/需求/进度/BUG/CHANGELOG 文档建立 | ✅ |
| M6 质量加固 | 2026-08-02 | 单元测试(vitest)、ESLint、CJS 导出修复、周起始修复、版本号 | ✅ |
| M7 功能深化 | 2026-08-02 ~ 08-03 | 称骨数据修正、八字流派/出生地/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日 | ✅ |
| M8 收尾 | 2026-08-03 | README、文档整理、源码打包(node_modules 清理) | ✅ |
> 注:主体功能于 2026-06-06 ~ 06-07 完成;2026-08-02 ~ 08-03 进行质量加固与功能深化。
## 2. 当前状态
- **可运行**:`pnpm dev` 可启动,`pnpm build` 可产出 core(ESM+CJS)+ web 产物(含 PWA)。
- **质量**:50 个单元测试通过(vitest)、`pnpm lint` 通过(ESLint flat config + typescript-eslint)。
- 剩余 BUG 与技术债见 BUGS.md。
## 3. 待办清单(Backlog)
按优先级排序:
| 优先级 | 事项 | 类型 | 关联 | 状态 |
|---|---|---|---|---|
| P0 | 修复 core 包 `require` 导出指向不存在的 `dist/index.cjs` | BUG | BUG-01 | ✅ |
| P1 | 周起始设置生效 | 功能 | BUG-02 | ✅ |
| P1 | 为 core 算法补充单元测试 | 工程 | N5 | ✅ 50 例 |
| P2 | 六十四卦数据补全 | 算法 | C10 | ✅ 已核实 64/64 完整,测试锁定 |
| P2 | 互卦算法实现真·互卦(2-4/3-5 爻) | 算法 | C10 | ⬜ |
| P2 | 清理死代码(useDayDetail/useBazi、AnimatedPanel、CardHeader、未用 store action 等) | 工程 | — | ⬜ |
| P3 | PWA manifest 修复 | BUG | BUG-03 | ✅ |
| P3 | 农历周期收藏 UI | 功能 | M2 | ⬜ |
| P3 | 收藏列表页 | 功能 | M3 | ⬜ |
| P4 | 配置 lint 并修复 `pnpm lint` | 工程 | N6 | ✅ |
| P4 | 显示开关字段真正消费(showLunar 等) | 功能 | T6 | ⬜ |
| P4 | 版本号统一(0.1.0) | BUG | BUG-07 | ✅ |
| P4 | 称骨数据表整体修正(年表/日表/歌诀) | 算法 | C11 | ✅ |
| P4 | 占卜页称骨年索引用出生年干支 | 算法 | TECH-05 | ✅ |
| P5 | 每日运势当日八字取午时 → 改为可选时辰 | 算法 | C9 | ⬜ |
| P5 | 称骨极端总重仍取"最近值" → 提示超出范围 | 算法 | TECH-04 | ⬜ |
| P5 | README 编写 | 工程 | M8 | ✅ |
+142
View File
@@ -0,0 +1,142 @@
# 需求文档(Requirements & 功能清单)
> 最后更新:2026-08-02
> 状态说明:✅ 已完成 · 🟡 部分完成 / 有已知问题 · ⬜ 规划中
## 1. 需求总览
| 模块 | 状态 | 说明 |
|---|---|---|
| 历法核心引擎(@lunar/core) | ✅ | 农历/干支/黄历/八字/运势算法 |
| 万年历(首页 + 日历 + 日详情) | ✅ | 功能齐全(周起始设置已修复) |
| 八字排盘 | 🟡 | 功能齐全,含一个非类型安全补丁 |
| 每日运势 | ✅ | 依赖用户档案 |
| 占卜工具(梅花易数 / 称骨) | 🟡 | 算法为简化实现(见备注) |
| 节气 | ✅ | 24 节气列表 |
| 用户档案 + 设置 | 🟡 | 部分设置字段未生效 |
| 收藏 | 🟡 | 仅支持公历单日收藏 |
| PWA | ✅ | 已构建,manifest 有瑕疵 |
## 2. 功能点清单
### 2.1 历法核心(@lunar/core)
| # | 功能点 | 描述 | 状态 |
|---|---|---|---|
| C1 | 日期转换 | tyme4ts → DayInfo(公历/农历/干支/星座/生肖/节日/假日/月相) | ✅ |
| C2 | 月历生成 | `getMonthCalendar` 周×天网格,含相邻月补位 | ✅ |
| C3 | 黄历信息 | 宜忌、值神(日/时)、十二/廿八/九/六曜星、彭祖百忌、胎神、冲煞、纳音、三合/六合 | ✅ |
| C4 | 时辰黄历 | 12 时辰逐时黄历(buildHourlyAlmanac) | ✅ |
| C5 | 八字排盘 | 四柱、十神、藏干、十二长生、胎元/胎息、命宫/身宫、空亡、起运 | ✅ |
| C6 | 大运/流年 | 10 大运 + 10 流年 | ✅ |
| C7 | 干支关系 | 六合/三合/六冲/六害/相刑/三合局/天干合冲 | ✅ |
| C8 | 五行分析 | 五行力量统计(藏干 0.5 权重)、旺衰、平衡度 | ✅ |
| C9 | 每日运势 | 四柱逐柱 vs 当日干支打分(日 40%/月 25%/年 20%/时 15%),输出评分、宜忌、幸运色/数/方位、分项(感情/事业/财运/健康) | 🟡 当日八字固定取午时 |
| C10 | 梅花易数 | 时间起卦、变卦、互卦(简化)、体用关系、64 卦辞 | 🟡 互卦简化为上下互换(卦库 64/64 完整,已有测试) |
| C11 | 称骨算命 | 年/月/日/时称骨表 + 52 首解诗(2两1钱~7两2钱完整) | ✅ 数据表已按主流版本修正,歌诀补全(极端值仍取最近) |
| C12 | 历法信息 | 季节、所处节气/第几天/距下节气、儒略日、佛历年、伊斯兰历(Hijri) | ✅ |
| C13 | 佛教节日 | 农历 21 个重大佛教节日(`getBuddhistFestival`) | ✅ 日详情页 🪷 徽章展示 |
| C14 | 八字神煞 | 22 个常见神煞(`analyzeShensha`) | ✅ 八字页"神煞"卡片 |
| C15 | 大运/流年 vs 日主生克冲合 | 十神、五行生克、天干合冲、地支关系、吉凶(`analyzeFortuneGanzhi`) | ✅ |
| C16 | 流年·流月·流日分析 | 任意日期年/月/日干支 vs 日主(`getYearMonths` 按节气月) | ✅ 八字页交互浏览器 |
### 2.2 首页 HomePage(`/`)
| # | 功能点 | 状态 |
|---|---|---|
| H1 | 今日概览:日期、农历、干支、宜忌摘要 | ✅ |
| H2 | 运势预览(需档案) | ✅ |
| H3 | 快捷导航(占卜、节气) | ✅ |
| H4 | 值神/彭祖/胎神展示 | ✅ |
### 2.3 日历 CalendarPage(`/calendar`)
| # | 功能点 | 状态 |
|---|---|---|
| K1 | 月视图/周视图切换 | ✅ |
| K2 | 年/月切换(MonthYearPicker) | ✅ |
| K3 | 格子显示:节日/节气缩写、农历、周末/今日高亮、收藏星标、运势点 | ✅ |
| K4 | 点击日期 → 日详情页 | ✅ |
| K5 | 手势滑动切换月份 | ✅ |
| K6 | 周起始(周一/周日)设置生效 | ✅ 已修复(BUG-02) |
| K7 | 非本月时显示"回到今天"浮动按钮,点击返回当月 | ✅ 已修复(BUG-09) |
### 2.4 日详情 DayDetailPage(`/calendar/:date`)
| # | 功能点 | 状态 |
|---|---|---|
| D1 | 四柱、纳音、值星展示 | ✅ |
| D2 | 冲合害煞、宜忌列表 | ✅ |
| D3 | 值神、彭祖百忌、胎神 | ✅ |
| D4 | 12 时辰逐时黄历 | ✅ |
| D5 | 收藏按钮 | ✅ 仅公历收藏 |
### 2.5 八字 BaziPage(`/bazi`)
| # | 功能点 | 状态 |
|---|---|---|
| B1 | 出生档案管理(最多 3 个,持久化) | ✅ |
| B2 | 排盘:四柱 + 十神 + 藏干 | ✅ |
| B3 | 五行力量 / 十二长生展示 | ✅ |
| B4 | 起运 / 大运 / 流年 | ✅ |
| B5 | 称骨集成 | ✅ 使用出生年干支(占卜页已同步修正) |
| B6 | 日柱类型安全 | ✅ `solarAdjusted` 字段替代 `(as any)` 补丁 |
| B7 | 八字流派切换(晚子时换日 23点 / 0点换日) | ✅ 设置页可切换,排盘实时生效 |
| B8 | 出生地:全国 34 省级城市真太阳时校正 | ✅ 含跨日处理,受"真太阳时校正"开关控制 |
| B9 | 出生地:海外时区 → 北京时间(UTC+8)换算排盘 | ✅ 含跨日处理 |
| B10 | 神煞展示(22 个常见神煞,吉/凶/中性分组) | ✅ |
| B11 | 大运全量 10 条 + 流年(小运)10 条,均附生克冲合 | ✅ |
| B12 | 大运→流年→流月→流日交互下钻(按年度切换) | ✅ 点选日期跳转日详情 |
### 2.6 每日运势 DailyFortunePage(`/daily-fortune`)
| # | 功能点 | 状态 |
|---|---|---|
| F1 | 日期导航查看任意日运势 | ✅ |
| F2 | 无档案时引导创建 | ✅ |
| F3 | 评分等级、宜忌、幸运信息、分项得分 | ✅ |
### 2.7 占卜 DivinationPage(`/divination`)
| # | 功能点 | 状态 |
|---|---|---|
| P1 | 今日梅花易数卦象 + 体用分析 | 🟡 算法简化 |
| P2 | 称骨算命输入与结果 | 🟡 年索引用日干支近似 |
### 2.8 节气 SolarTermsPage(`/solar-terms`)
| # | 功能点 | 状态 |
|---|---|---|
| S1 | 全年 24 节气列表 | ✅ |
### 2.9 设置 SettingsPage(`/settings`)
| # | 功能点 | 状态 |
|---|---|---|
| T1 | 档案管理入口 | ✅ |
| T2 | 农历日期查询(阴阳历转换) | ✅ |
| T3 | 今日天干地支/纳音 | ✅ |
| T4 | 主题切换(亮/暗/跟随系统) | ✅ |
| T5 | 周起始设置 | ✅ 已修复(BUG-02) |
| T6 | 显示开关(农历/节气/假日)、八字来源 | 🟡 字段已持久化但未消费 |
| T7 | 关于:版本号 | ✅ 已统一为 0.1.0(BUG-07) |
| T8 | 真太阳时校正开关(默认开启) | ✅ 关闭后按北京时间排盘 |
### 2.10 收藏 Bookmarks
| # | 功能点 | 状态 |
|---|---|---|
| M1 | 收藏/取消收藏日期,日历格星标 | ✅ |
| M2 | 农历周期收藏(每年/月循环) | ⬜ 字段已预留,UI 未提供 |
| M3 | 收藏列表页 | ⬜ 规划中 |
### 2.11 非功能需求
| # | 需求 | 状态 |
|---|---|---|
| N1 | PWA 可安装、离线可用 | ✅ 已构建 |
| N2 | 移动端优先响应式 | ✅ |
| N3 | 暗色模式(跟随系统) | ✅ |
| N4 | 页面懒加载 + 骨架屏 + 错误边界 | ✅ |
| N5 | 自动化测试 | ✅ 2026-08-02 引入 vitest,29 个用例(历法/八字/运势/梅花/称骨/干支) |
| N6 | Lint / 代码规范 | ✅ 2026-08-02 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过 |
+74
View File
@@ -0,0 +1,74 @@
# 开发回顾记录(Retrospect)
> 会话日期:2026-08-02 ~ 2026-08-03
> 用途:记录本次会话"做了什么 / 没做什么 / 有疑问的事项",便于事后回顾与决策。
## 一、本次完成的工作
### 1. 文档体系(docs/)
- 新建 `docs/`:ARCHITECTURE / REQUIREMENTS / PROGRESS / BUGS / CHANGELOG,另加根 README.md
- 修正 AGENTS.md / CLAUDE.md 的过时信息(PWA 状态、页面/Store/API 补全),并持续同步
### 2. BUG 修复(BUGS.md 均已标记 ✅)
| ID | 内容 |
|---|---|
| BUG-01 | core 包 CJS 导出:tsup 增加 cjs 产物,`require()` 实测可用 |
| BUG-02 | 周起始设置生效:settings → useCalendar → getMonthCalendar(weekStart) → WeekDayBar |
| BUG-03 | PWA manifest 对齐(lang=zh-CN、theme_color=#FFFBF5) |
| BUG-05 | 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处) |
| BUG-07 | 版本号统一 v0.1.0 |
| BUG-08 | getMonthCalendar 死分支移除 |
| BUG-09 | "回到今天"按钮条件恒 false,改为基于 viewDate 判断 |
| BUG-10 | 日详情时辰列表重复 React key(早子/晚子均"子"),改用 ganzhi 作 key |
| TECH-06 | `(as any)._solarAdjusted` → `BaziFullResult.solarAdjusted` 类型化 |
### 3. 功能深化
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect`(晚子时算次日/当日),设置页开关
- **出生时间选择器**:时/分下拉 + 时辰格可点
- **出生地全国化 + 海外时区**:34 省级城市(真太阳时校正)+ 海外 UTC-12~+14 换算北京时间,均支持跨日
- **真太阳时校正开关**(设置页,默认开启)
- **宜忌完整显示**:根因是首页 `slice(0,6)`,引擎本有 22 条宜
- **历法信息增强**:DayInfo 新增 季节/所处节气/第几天/距下节气/儒略日/佛历年/伊斯兰历(tyme4ts 原生 Hijri)
- **佛教节日**:新增 `getBuddhistFestival`(农历 21 个节日),日详情 🪷 徽章
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞),八字页"神煞"卡
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(十神/五行生克/合冲害刑/吉凶)
- **大运→流年→流月→流日交互下钻**:新增 `getYearMonths`(节气月);八字页交互浏览器,流日可跳日详情
### 4. 质量
- 单元测试 29 → **50 个**(vitest),覆盖历法/八字/运势/梅花/称骨/干支/五行/神煞/流月/流年
- 浏览器 E2E 验证(headless Chrome + CDP 脚本,无新增依赖):周起始、回到今天、流派、出生地、太阳时开关、宜忌、历法信息、神煞、大运流年交互 全部实测通过
### 5. 工程清理
- 整理全部文档;删除 node_modules / dist / tsbuildinfo;源码 241M → 764K;打包 164K(/tmp/lunar-source-20260803.tar.gz)
## 二、未完成 / 待办(详见 PROGRESS.md Backlog)
| 事项 | 说明 |
|---|---|
| 互卦真算法 | 梅花易数互卦仍为上下卦互换简化(2-4/3-5 爻法未实现) |
| 死代码清理 | useDayDetail/useBazi hooks、AnimatedPanel、CardHeader、CalendarSkeleton/BaziSkeleton、ui store 未用 action、settings 显示开关字段、calendar store weekStart、bookmarks getByDate、utils 未用函数、tailwind-variants 依赖 |
| 显示开关字段 | settings.showLunar/showSolarTerm/showHoliday/eightCharProvider 已持久化但未消费(其中 eightCharProvider 已被 ziHourSect 替换) |
| 农历周期收藏 | bookmarks 的 isLunar 字段已预留,UI 未提供;收藏列表页未做 |
| 午时假设 | calculateDailyFortune 当日八字固定取午时(TECH-01) |
| 称骨极端值 | 超出 2两1钱~7两2钱范围的总重仍取"最近值"(TECH-04 残余) |
| 紫微斗数 | 未做(大工程,用户同意先做神煞) |
| web 端自动化测试 | 仅 core 有单测;web 无测试框架 |
| git | 项目未初始化 git 仓库,无版本管理与回滚能力 |
## 三、有疑问 / 待确认事项
1. **称骨年表存在两套网络变体**:本实现采用主流版本(算准网/网易/sunfinelife 三家一致:丙子16钱、戊子15钱等),但另一套变体(丙子19钱、戊子12钱等)也在流通。若用户希望换另一套,改 `boneWeight.ts` 的 `YEAR_WEIGHTS` 即可。
2. **天厨贵人查法有版本差异**:本实现采用主流版(甲巳、乙午、丙巳、丁午、戊申、己酉、庚亥、辛子、壬寅、癸卯);传统版(丙寅、丁酉、戊申、己未、庚亥、辛戌、壬卯、癸子)也有出处。代码注释已注明。
3. **tyme4ts 十神方向语义**:实测 `A.getTenStar(B)` 返回"B 以 A 为日主"的十神。`dailyMatch.ts` 中的用法方向(userPillar vs dayPillar 同位置比较)语义上存疑,但**未改动**(避免影响每日运势结果);新写的 `fortuneLuck` 已按正确方向实现。需确认 dailyMatch 是否也要调整。
4. **佛历年**:按公历 + 543(泰国惯例);bmcx 示例 2570 对应的是其他年份。
5. **佛教节日表**:21 个为通行版本,个别日期(如腊月廿九华严菩萨圣诞)在不同资料有出入。
6. **默认出生地变化**:默认从"120°E 不校正"改为"北京 116.4°E(-14 分钟真太阳校正)",临界时辰的结果会变。太阳时校正开关默认开启——用户接受度未知,如需要可默认关闭。
7. **BUG-04(HomePage InfoCard highlight)**:`highlight` 传布尔但被当作样式类 `border-primary/30` 处理,语义不清,需确认意图(是要"高亮边框"还是别的)。
8. **起运前年份 UX**:大运流年浏览器中,出生当年(起运前)不在任何大运区间,自动落到第一柱大运(如 2026 年出生 → 从 2028 起显示)。是否要显示"起运前"年份待定。
9. **pnpm-workspace.yaml `allowBuilds`**:非 pnpm 标准字段(应为 onlyBuiltDependencies),疑似无效但无害,未改。
10. **打包清理**:已删除 node_modules/dist,开发前需 `pnpm install`。
## 四、验证方式备忘
- E2E 通过 headless Chrome(Playwright 缓存目录的 chromium headless shell)+ CDP 协议 Node 脚本驱动,未引入 Playwright npm 依赖;脚本存于 `/tmp/lunar-*.mjs`(会话临时文件,未入库)。
- 所有功能均以"单测 + 浏览器实测"双重验证;仅 UI 纯样式类改动(如时间选择器)以 tsc 构建验证为主。
+11
View File
@@ -0,0 +1,11 @@
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['**/dist/**', '**/node_modules/**', '**/*.tsbuildinfo'] },
tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
);
+24
View File
@@ -0,0 +1,24 @@
{
"name": "lunar-calendar",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "万年历 - Chinese Almanac Calendar App",
"scripts": {
"dev": "pnpm --filter @lunar/web dev",
"build": "pnpm --filter @lunar/core build && pnpm --filter @lunar/web build",
"preview": "pnpm --filter @lunar/web preview",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"clean": "pnpm -r clean"
},
"engines": {
"node": ">=18",
"pnpm": ">=9"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.8.0",
"typescript-eslint": "^8.65.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@lunar/core",
"version": "0.1.0",
"private": true,
"description": "Core calculation engine for lunar calendar app",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"test": "vitest run",
"lint": "eslint .",
"clean": "rm -rf dist"
},
"dependencies": {
"tyme4ts": "^1.5.1"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { birthInfoToBazi } from '../index';
const GANZHI = /^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/;
describe('bazi transformer', () => {
const bazi = birthInfoToBazi({ year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male' });
it('produces four pillars with valid ganzhi', () => {
expect(bazi.eightChar.yearPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.monthPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.dayPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.hourPillar.ganzhi).toMatch(GANZHI);
});
it('exposes day master info', () => {
expect(bazi.eightChar.dayMasterStem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
expect(['木', '火', '土', '金', '水']).toContain(bazi.eightChar.dayMasterElement);
});
it('returns 10 decade fortunes and 10 annual fortunes', () => {
expect(bazi.decadeFortunes).toHaveLength(10);
expect(bazi.annualFortunes).toHaveLength(10);
expect(bazi.decadeFortunes[0].ganzhi).toMatch(GANZHI);
});
it('attaches each decade its own 10 annual fortunes', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
expect(chart.decadeFortunes).toHaveLength(10);
for (const decade of chart.decadeFortunes) {
expect(decade.annualFortunes).toHaveLength(10);
}
expect(chart.decadeFortunes[0].annualFortunes.map(f => f.year))
.toEqual([1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004]);
expect(chart.decadeFortunes[3].annualFortunes.map(f => f.year))
.toEqual([2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034]);
// top-level annualFortunes mirrors the first decade
expect(chart.annualFortunes).toEqual(chart.decadeFortunes[0].annualFortunes);
});
it('returns child limit with non-negative start age', () => {
expect(bazi.childLimit.startAge).toBeGreaterThanOrEqual(0);
expect(bazi.childLimit.forward).toBeTypeOf('boolean');
expect(bazi.childLimit.birthTime).toContain('1990年5月15日');
});
it('matches paipan sect-2 start for 1989-12-22 15:20 male', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
expect(chart.childLimit.forward).toBe(false);
expect(chart.childLimit.yearCount).toBe(5);
expect(chart.childLimit.monthCount).toBe(0);
expect(chart.childLimit.dayCount).toBe(20);
expect(chart.childLimit.hourCount).toBe(0);
expect(chart.childLimit.startTime).toBe('1995年1月11日 15:20:00');
});
it('uses the first-luck transition as the displayed start time', () => {
const sameDay = birthInfoToBazi({ year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male' });
expect(sameDay.childLimit.birthTime).toContain('1990年8月8日');
expect(sameDay.childLimit.startTime).toContain('2000年11月14日');
expect(sameDay.childLimit.startTime).not.toBe(sameDay.childLimit.birthTime);
});
it('includes hidden stems with ten stars', () => {
const yearHs = bazi.eightChar.yearPillar.hideStems;
expect(yearHs.length).toBeGreaterThan(0);
expect(yearHs[0].stem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
});
it('default sect: late zi (23:30) rolls to next day', () => {
const late = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male' });
expect(late.eightChar.dayPillar.ganzhi).toBe('庚戌');
expect(late.eightChar.hourPillar.ganzhi).toBe('丙子');
});
it('earlyZiSameDay sect: late zi (23:30) keeps the current day', () => {
const early = birthInfoToBazi({
year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male', ziSect: 'earlyZiSameDay',
});
expect(early.eightChar.dayPillar.ganzhi).toBe('己酉');
expect(early.eightChar.hourPillar.ganzhi).toBe('甲子');
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { calculateBoneWeight } from '../index';
describe('bone weight', () => {
it('sums weights correctly for 甲子年正月初一子时', () => {
const r = calculateBoneWeight(0, 1, 1, 0);
expect(r.yearWeight).toBe(12);
expect(r.monthWeight).toBe(6);
expect(r.dayWeight).toBe(5);
expect(r.hourWeight).toBe(16);
expect(r.totalWeight).toBe(39);
expect(r.totalLiang).toBe(3);
expect(r.totalQian).toBe(9);
});
it('returns a non-empty interpretation with a valid fortune grade', () => {
const r = calculateBoneWeight(12, 6, 15, 6);
expect(r.interpretation.length).toBeGreaterThan(0);
expect(['上上', '上', '中上', '中', '中下', '下']).toContain(r.fortune);
});
it('falls back to the nearest interpretation for unknown totals', () => {
// 4 + 9 + 9 + 9 = 31 → exact match exists; use an out-of-range input
const r = calculateBoneWeight(0, 1, 1, 11); // 12 + 6 + 5 + 6 = 29
expect(r.totalWeight).toBe(29);
expect(r.interpretation.length).toBeGreaterThan(0);
});
it('year table matches the mainstream 称骨年表', () => {
const cases: [number, number][] = [
[0, 12], // 甲子
[7, 8], // 辛未
[16, 12], // 庚辰
[17, 6], // 辛巳
[18, 8], // 壬午
[21, 15], // 乙酉
[51, 8], // 乙卯
[54, 19], // 戊午
];
for (const [idx, wt] of cases) {
const r = calculateBoneWeight(idx, 1, 1, 0);
expect(r.yearWeight, `year index ${idx}`).toBe(wt);
}
});
it('day table: 初五 is 1两6钱', () => {
const r = calculateBoneWeight(0, 1, 5, 0);
expect(r.dayWeight).toBe(16);
});
it('previously missing poems (5两8钱~7两1钱) resolve exactly, not by nearest match', () => {
const cases: [number, number, number, number, number, string][] = [
[18, 6, 26, 5, 58, '雁塔题名'],
[15, 6, 8, 7, 59, '甲第之中'],
[24, 3, 18, 6, 61, '金榜客'],
[42, 6, 26, 5, 63, '定中高科'],
[54, 9, 18, 6, 65, '安邦'],
[54, 6, 8, 5, 67, '田园家业'],
[54, 6, 18, 5, 69, '前禄星'],
[54, 3, 26, 5, 71, '公侯卿相'],
];
for (const [y, m, d, h, total, phrase] of cases) {
const r = calculateBoneWeight(y, m, d, h);
expect(r.totalWeight, `expected total ${total}`).toBe(total);
expect(r.interpretation, `poem for ${total}`).toContain(phrase);
}
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { getDayInfo, getMonthCalendar, getTodayInfo } from '../index';
describe('day transformers', () => {
it('returns correct lunar info for 2024-02-10 (春节正月初一)', () => {
const di = getDayInfo(2024, 2, 10);
expect(di.solarDate).toBe('2024-02-10');
expect(di.lunarMonth).toBe(1);
expect(di.lunarDay).toBe(1);
expect(di.lunarDayName).toBe('初一');
expect(di.lunarYearGanzhi).toMatch(/^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/);
expect(di.weekDayIndex).toBe(6);
expect(di.isWeekend).toBe(true);
});
it('getTodayInfo returns today', () => {
const now = new Date();
const di = getTodayInfo();
expect(di.solarYear).toBe(now.getFullYear());
expect(di.solarMonth).toBe(now.getMonth() + 1);
expect(di.solarDay).toBe(now.getDate());
expect(di.isToday).toBe(true);
});
it('getMonthCalendar respects weekStart', () => {
// 2024-02-01 is a Thursday
const sunFirst = getMonthCalendar(2024, 2, 0);
const monFirst = getMonthCalendar(2024, 2, 1);
for (const row of sunFirst) expect(row).toHaveLength(7);
for (const row of monFirst) expect(row).toHaveLength(7);
expect(sunFirst[0][0].weekDayIndex).toBe(0); // Sunday first
expect(monFirst[0][0].weekDayIndex).toBe(1); // Monday first
// Both grids must contain the 1st of the month
const flatSun = sunFirst.flat();
const flatMon = monFirst.flat();
expect(flatSun.some(d => d.solarDate === '2024-02-01')).toBe(true);
expect(flatMon.some(d => d.solarDate === '2024-02-01')).toBe(true);
});
it('provides season, term progress, julian, buddhist era and hijri date', () => {
const di = getDayInfo(2026, 8, 3); // 大暑期间,农历六月廿一
expect(di.season).toBe('夏季');
expect(di.termDayIndex).toBeGreaterThan(0);
expect(di.nextSolarTerm).toBe('立秋');
expect(di.daysToNextTerm).toBe(4);
expect(di.julianDay).toBeGreaterThan(2450000);
expect(di.buddhistYear).toBe(2026 + 543);
expect(di.hijriDate).toMatch(/^\d{4}年\d{2}月\d{2}日$/);
});
it('marks Buddhist festivals by lunar date', () => {
// 2024-05-15 is 佛诞(浴佛节)农历四月初八
const di = getDayInfo(2024, 5, 15);
expect(di.lunarMonth).toBe(4);
expect(di.lunarDay).toBe(8);
expect(di.buddhistFestival).toBe('释迦牟尼佛圣诞(浴佛节)');
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { analyzeElementBalance, birthInfoToBazi } from '../index';
describe('element balance', () => {
const eightChar = birthInfoToBazi({
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male',
}).eightChar;
const profile = analyzeElementBalance(eightChar);
it('has positive total that matches sum of parts', () => {
expect(profile.total).toBeGreaterThan(0);
expect(profile.wood + profile.fire + profile.earth + profile.metal + profile.water)
.toBeCloseTo(profile.total);
});
it('identifies dominant and weakest elements', () => {
expect(['木', '火', '土', '金', '水']).toContain(profile.dominant);
expect(['木', '火', '土', '金', '水']).toContain(profile.weakest);
expect(profile.isBalanced).toBeTypeOf('boolean');
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { calculateDailyFortune, birthInfoToBazi } from '../index';
const SCORE_LEVELS = ['great', 'good', 'fair', 'poor', 'bad'] as const;
describe('daily fortune', () => {
const eightChar = birthInfoToBazi({
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'female',
}).eightChar;
const fortune = calculateDailyFortune(eightChar, new Date(2026, 7, 2));
it('returns bounded score and valid level', () => {
expect(fortune.overallScore).toBeGreaterThanOrEqual(-100);
expect(fortune.overallScore).toBeLessThanOrEqual(100);
expect(SCORE_LEVELS).toContain(fortune.scoreLevel);
});
it('evaluates all four pillars', () => {
expect(fortune.pillarRelationships).toHaveLength(4);
expect(fortune.pillarRelationships.map(r => r.pillar)).toEqual(['year', 'month', 'day', 'hour']);
});
it('returns the requested date', () => {
expect(fortune.date).toBe('2026-08-02');
});
it('produces suggestions and category scores', () => {
expect(fortune.suggestions.length).toBeGreaterThan(0);
expect(fortune.luckyAspects.length).toBeGreaterThan(0);
const { love, career, wealth, health } = fortune.categoryScores;
expect([love, career, wealth, health].every(v => v >= -100 && v <= 100)).toBe(true);
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { analyzeFortuneGanzhi, birthInfoToBazi } from '../index';
describe('fortune luck (大运/流年 vs 日主)', () => {
// 2026-08-03 12:00: 日干己、日支酉
const bazi = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 12, minute: 0, gender: 'male' });
const dayStem = bazi.eightChar.dayPillar.heavenStem;
const dayBranch = bazi.eightChar.dayPillar.earthBranch;
it('甲子 vs 己酉: 正官 + 天干合 + 克我 → 吉', () => {
const r = analyzeFortuneGanzhi('甲子', dayStem, dayBranch);
expect(r.tenStar).toBe('正官');
expect(r.stemCombine).toBe(true);
expect(r.elementRelation).toBe('克我');
expect(r.level).toBe('吉');
});
it('乙酉 vs 己酉: 七杀 + 自刑 → 凶', () => {
const r = analyzeFortuneGanzhi('乙酉', dayStem, dayBranch);
expect(r.tenStar).toBe('七杀');
expect(r.branchPunish).toBe(true); // 酉酉自刑
expect(r.level).toBe('凶');
});
it('丙子 vs 己未: 正印 + 子未六害', () => {
const r = analyzeFortuneGanzhi('丙子', '己', '未');
expect(r.tenStar).toBe('正印');
expect(r.branchHarm).toBe(true);
});
it('all decade fortunes produce valid luck info', () => {
for (const df of bazi.decadeFortunes) {
const r = analyzeFortuneGanzhi(df.ganzhi, dayStem, dayBranch);
expect(['比和', '生我', '我生', '克我', '我克']).toContain(r.elementRelation);
expect(['吉', '凶', '平']).toContain(r.level);
expect(r.score).toBeGreaterThanOrEqual(-10);
expect(r.score).toBeLessThanOrEqual(10);
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { calculatePlumBlossom } from '../index';
describe('plum blossom', () => {
it('produces expected hexagram for known input (2026-08-02 12:00)', () => {
const r = calculatePlumBlossom(2026, 8, 2, 12);
expect(r.upperTrigram.name).toBe('巽');
expect(r.lowerTrigram.name).toBe('兑');
expect(r.originalHexagram.name).toBe('风泽中孚');
expect(r.originalHexagram.changingLine).toBe(4);
expect(r.relationship).toBeTruthy();
});
it('is deterministic', () => {
const a = calculatePlumBlossom(2024, 3, 5, 8);
const b = calculatePlumBlossom(2024, 3, 5, 8);
expect(a).toEqual(b);
});
it('dataset covers all 64 hexagram combinations', () => {
const srcFile = fileURLToPath(new URL('../calculators/plumBlossom.ts', import.meta.url));
const src = fs.readFileSync(srcFile, 'utf8');
const keys = [...src.matchAll(/'(\d,\d)':/g)].map(m => m[1]);
expect(keys).toHaveLength(64);
expect(new Set(keys).size).toBe(64);
for (let upper = 1; upper <= 8; upper++) {
for (let lower = 1; lower <= 8; lower++) {
expect(keys).toContain(`${upper},${lower}`);
}
}
});
it('never falls back to a placeholder across a broad date grid', () => {
for (let y = 2000; y <= 2030; y++) {
for (let m = 1; m <= 12; m++) {
for (let d = 1; d <= 28; d += 7) {
const r = calculatePlumBlossom(y, m, d, 12);
// Real hexagram names are 3+ chars; fallback names are 2-char concatenations
expect(r.originalHexagram.name.length).toBeGreaterThan(2);
}
}
}
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from '../index';
describe('relationship calculator', () => {
it('detects 六合 (子丑合)', () => {
expect(getBranchRelationship('子', '丑').combine).toBe(true);
expect(getBranchRelationship('子', '午').combine).toBe(false);
});
it('detects 六冲 (子午冲)', () => {
expect(getBranchRelationship('子', '午').opposite).toBe(true);
});
it('detects 三合 (申子辰水局)', () => {
const rel = getBranchRelationship('申', '辰');
expect(rel.threeCombine).toBe(true);
expect(rel.formation).toBe('水局');
});
it('detects 六害 (子未害)', () => {
expect(getBranchRelationship('子', '未').harm).toBe(true);
});
it('detects 相刑 (寅巳无恩之刑)', () => {
expect(getBranchRelationship('寅', '巳').punish).toBe(true);
});
it('detects 天干合 (甲己合)', () => {
expect(checkStemCombine('甲', '己')).toBe(true);
expect(checkStemCombine('甲', '乙')).toBe(false);
});
it('detects 天干冲 (甲庚冲)', () => {
expect(checkStemOpposite('甲', '庚')).toBe(true);
});
it('computes ten star for a day master (甲日主见戊土 → 偏财)', () => {
expect(getTenStarRelationship('甲', '戊')).toContain('财');
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { analyzeShensha, birthInfoToBazi } from '../index';
describe('shensha', () => {
// 2026-08-03 12:00: 年柱丙午、月柱乙未、日柱己酉、时柱庚午
const bazi = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 12, minute: 0, gender: 'male' });
it('does not claim 天乙贵人 from year stem alone (day stem 己 → 子申 not in chart)', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name === '天乙贵人');
expect(t).toBeUndefined();
});
it('1989-12-22 15:20 male has no 天乙贵人 (day stem 丙 → 亥酉 not in chart)', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
const s = analyzeShensha(chart.eightChar);
expect(s.find(x => x.name === '天乙贵人')).toBeUndefined();
});
it('2020-01-01 12:00 male has 天乙贵人 at 日支 via day stem 癸', () => {
const chart = birthInfoToBazi({ year: 2020, month: 1, day: 1, hour: 12, minute: 0, gender: 'male' });
expect(chart.eightChar.dayPillar.heavenStem).toBe('癸');
const s = analyzeShensha(chart.eightChar);
const t = s.find(x => x.name === '天乙贵人');
expect(t).toBeDefined();
expect(t!.foundIn).toContain('日支');
expect(t!.anchors.some(a => a.includes('日干'))).toBe(true);
});
it('finds 文昌贵人 by day stem 己 → 酉, hit 日支酉', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name === '文昌贵人');
expect(t).toBeDefined();
expect(t!.anchors.some(a => a.includes('日干'))).toBe(true);
});
it('finds 禄神 and 羊刃 by day/year stem 己/丙 → 午', () => {
const s = analyzeShensha(bazi.eightChar);
const lu = s.find(x => x.name === '禄神');
const yang = s.find(x => x.name === '羊刃');
expect(lu).toBeDefined();
expect(lu!.foundIn).toEqual(expect.arrayContaining(['年支', '时支']));
expect(yang).toBeDefined();
});
it('finds 桃花 by day branch 酉 (巳酉丑) → 午', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name.startsWith('桃花'));
expect(t).toBeDefined();
expect(t!.foundIn).toContain('年支');
});
it('finds 将星 and 红鸾', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.find(x => x.name === '将星')).toBeDefined(); // 年支午(寅午戌) → 午
expect(s.find(x => x.name === '红鸾')).toBeDefined(); // 年支午 → 酉,日支酉
});
it('does not flag 魁罡/阴差阳错 for day 己酉', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.find(x => x.name === '魁罡')).toBeUndefined();
expect(s.find(x => x.name === '阴差阳错')).toBeUndefined();
});
it('all stem rules cover all 10 stems and branch rules cover 12 branches', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { getYearMonths } from '../index';
describe('getYearMonths (流月)', () => {
it('returns 12 months starting from 立春', () => {
const months = getYearMonths(2026);
expect(months).toHaveLength(12);
expect(months[0].name).toBe('正月');
expect(months[0].startDate).toBe('2026-02-04'); // 立春 2026
expect(months[0].ganzhi).toBe('庚寅'); // 丙午年 五虎遁 → 寅月庚寅
expect(months[11].name).toBe('腊月');
expect(months[11].startDate).toMatch(/^2027-01-/); // 小寒 2027
});
it('endDate is the day before the next 节', () => {
const months = getYearMonths(2026);
expect(months[0].endDate).toBe('2026-03-04'); // 惊蛰 2026-03-05 前一天
expect(months[1].ganzhi).toBe('辛卯'); // 惊蛰起卯月
});
it('all month ganzhi match the 五虎遁 sequence', () => {
const months = getYearMonths(2026);
const seq = ['庚寅', '辛卯', '壬辰', '癸巳', '甲午', '乙未', '丙申', '丁酉', '戊戌', '己亥', '庚子', '辛丑'];
expect(months.map(m => m.ganzhi)).toEqual(seq);
});
});
@@ -0,0 +1,161 @@
/**
* 袁天罡称骨算命法 (Bone Weight Fortune Telling)
* Based on year/month/day/hour pillars to calculate total "bone weight"
* Each unit = 钱 (1两 = 10钱)
*/
export interface BoneWeightResult {
yearWeight: number; // in 钱
monthWeight: number;
dayWeight: number;
hourWeight: number;
totalWeight: number; // in 钱
totalLiang: number; // 两
totalQian: number; // 钱
interpretation: string; // The fate poem/interpretation
fortune: '上上' | '上' | '中上' | '中' | '中下' | '下';
}
// Year bone weight table (by 干支 year stem-branch)
// 主流称骨年表(百度百科/网易/算准网等通行版本):year % 60 → weight in 钱
const YEAR_WEIGHTS: Record<number, number> = {
0: 12, 1: 9, 2: 6, 3: 7, 4: 12, 5: 5, 6: 9, 7: 8, 8: 7, 9: 8,
10: 15, 11: 9, 12: 16, 13: 8, 14: 8, 15: 19, 16: 12, 17: 6, 18: 8, 19: 7,
20: 5, 21: 15, 22: 6, 23: 16, 24: 15, 25: 7, 26: 9, 27: 12, 28: 10, 29: 7,
30: 15, 31: 6, 32: 5, 33: 14, 34: 14, 35: 9, 36: 7, 37: 7, 38: 9, 39: 12,
40: 8, 41: 7, 42: 13, 43: 5, 44: 14, 45: 5, 46: 9, 47: 17, 48: 5, 49: 7,
50: 12, 51: 8, 52: 8, 53: 6, 54: 19, 55: 6, 56: 8, 57: 16, 58: 10, 59: 6,
};
// Month bone weight (lunar month 1-12)
const MONTH_WEIGHTS: Record<number, number> = {
1: 6, 2: 7, 3: 18, 4: 9, 5: 5, 6: 16,
7: 9, 8: 15, 9: 18, 10: 8, 11: 9, 12: 5,
};
// Day bone weight (lunar day 1-30)
const DAY_WEIGHTS: Record<number, number> = {
1: 5, 2: 10, 3: 8, 4: 15, 5: 16, 6: 15, 7: 8, 8: 16, 9: 8, 10: 16,
11: 9, 12: 17, 13: 8, 14: 17, 15: 10, 16: 8, 17: 9, 18: 18, 19: 5, 20: 15,
21: 10, 22: 9, 23: 8, 24: 9, 25: 15, 26: 18, 27: 7, 28: 8, 29: 16, 30: 6,
};
// Hour bone weight (时辰, 地支 index 0-11)
const HOUR_WEIGHTS: Record<number, number> = {
0: 16, // 子时 23-01
1: 6, // 丑时 01-03
2: 7, // 寅时 03-05
3: 10, // 卯时 05-07
4: 9, // 辰时 07-09
5: 16, // 巳时 09-11
6: 10, // 午时 11-13
7: 8, // 未时 13-15
8: 8, // 申时 15-17
9: 9, // 酉时 17-19
10: 6, // 戌时 19-21
11: 6, // 亥时 21-23
};
// Interpretation for each total weight (in 钱)
const INTERPRETATIONS: Record<number, { text: string; fortune: string }> = {
21: { text: '短命非业谓大凶,平生灾难事重重,凶祸频临陷逆境,终世困苦事不成。', fortune: '下' },
22: { text: '身寒骨冷苦伶仃,此命推来行乞人,劳劳碌碌无度日,终年打拱过平生。', fortune: '下' },
23: { text: '此命推来骨格轻,求谋作事事难成,妻儿兄弟应难许,别处他乡作散人。', fortune: '下' },
24: { text: '此命推来福禄无,门庭困苦总难荣,六亲骨肉皆无靠,流浪他乡作老翁。', fortune: '下' },
25: { text: '此命推来祖业微,门庭营度似稀奇,六亲骨肉如冰炭,一世勤劳自把持。', fortune: '中下' },
26: { text: '平生衣禄苦中求,独自营谋事不休,离祖出门宜早计,晚来衣禄自无休。', fortune: '中下' },
27: { text: '一生作事少商量,难靠祖宗怎主张,独马单枪空做去,早年晚岁总无长。', fortune: '中下' },
28: { text: '一生行事似飘蓬,祖宗产业在梦中,若不过房改名姓,也当移徒二三通。', fortune: '中下' },
29: { text: '初年运限未曾亨,纵有功名在后成,须过四旬才可立,移居改姓始为良。', fortune: '中' },
30: { text: '劳劳碌碌苦中求,东奔西走何日休,若使终身勤与俭,老来稍可免忧愁。', fortune: '中' },
31: { text: '忙忙碌碌苦中求,何日云开见日头,难得祖基家可立,中年衣食渐无忧。', fortune: '中' },
32: { text: '初年运蹇事难谋,渐有财源如水流,到得中年衣食旺,那时名利一齐收。', fortune: '中上' },
33: { text: '早年做事事难成,百年勤劳枉费心,半世自如流水去,后来运到始得金。', fortune: '中上' },
34: { text: '此命福气果如何,僧道门中衣禄多,离祖出家方为妙,朝晚拜佛念弥陀。', fortune: '中上' },
35: { text: '生平福量不周全,祖业根基觉少传,营事生涯宜守旧,时来衣食胜从前。', fortune: '中' },
36: { text: '不须劳碌过平生,独自成家福不轻,早有福星常照命,任君行去百般成。', fortune: '上' },
37: { text: '此命般般事不成,弟兄少力自孤行,虽然祖业须微有,来得明时去不明。', fortune: '中下' },
38: { text: '一身骨肉最清高,早入簧门姓氏标,待到年将三十六,蓝衫脱去换红袍。', fortune: '上' },
39: { text: '此命终身运不通,劳劳作事尽皆空,苦心竭力成家计,到得那时在梦中。', fortune: '中下' },
40: { text: '平生衣禄是绵长,件件心中自主张,前面风霜多受过,后来必定享安康。', fortune: '上' },
41: { text: '此命推来自不同,为人能干异凡庸,中年还有逍遥福,不比前时运未通。', fortune: '上' },
42: { text: '得宽怀处且宽怀,何用双眉皱不开,若使中年命运济,那时名利一齐来。', fortune: '上' },
43: { text: '为人心性最聪明,作事轩昂近贵人,衣禄一生天注定,不须劳碌是丰亨。', fortune: '上上' },
44: { text: '万事由天莫苦求,须知福碌赖人修,当年财帛难如意,晚景欣然便不忧。', fortune: '中上' },
45: { text: '名利推求竟若何,前番辛苦后奔波,命中难养男和女,骨肉扶持也不多。', fortune: '中' },
46: { text: '东西南北尽皆通,出姓移居更觉隆,衣禄无穷无数定,中年晚景一般同。', fortune: '上' },
47: { text: '此命推求旺末年,妻荣子贵自怡然,平生原有滔滔福,可卜财源若水泉。', fortune: '上' },
48: { text: '初年运道未曾通,几许蹉跎命亦穷,兄弟六亲无依靠,一生事业晚来整。', fortune: '中' },
49: { text: '此命推来福不轻,自成自立显门庭,从来富贵人钦敬,使婢差奴过一生。', fortune: '上' },
50: { text: '为利为名终日劳,中年福禄也多遭,老来自有财星照,不比前番目下高。', fortune: '上' },
51: { text: '一世荣华事事通,不须劳碌自亨通,兄弟叔侄皆如意,家业成时福禄宏。', fortune: '上' },
52: { text: '一世亨通事事能,不须劳苦自然宁,宗族有光欣喜甚,家产丰盈自称心。', fortune: '上' },
53: { text: '此格推来福泽宏,兴家立业在其中,一生衣食安排定,却是人间一福翁。', fortune: '上' },
54: { text: '此命推来厚且清,诗书满腹看功成,丰衣足食自然稳,正是人间有福人。', fortune: '上上' },
55: { text: '走马扬鞭争利名,少年作事费筹论,一朝福禄源源至,富贵荣华显六亲。', fortune: '上' },
56: { text: '此格推来礼义通,一身福禄用无穷,甜酸苦辣皆尝过,滚滚财源稳且丰。', fortune: '上' },
57: { text: '福禄丰盈万事全,一身荣耀乐天年,名扬威震人争羡,此世逍遥宛似仙。', fortune: '上上' },
58: { text: '平生福禄自然来,名利双全福禄偕,雁塔题名为贵客,紫袍玉带走金阶。', fortune: '上' },
59: { text: '细推此格妙且清,必定财高礼义通,甲第之中应有分,扬鞭走马显威荣。', fortune: '上' },
60: { text: '一朝金榜快题名,显祖荣宗大器成,衣禄定然无欠缺,田园财帛更丰盈。', fortune: '上上' },
61: { text: '不作朝中金榜客,定为世上一财翁,聪明天赋经书熟,名显高科自是荣。', fortune: '上上' },
62: { text: '此命生来福不穷,读书必定显亲宗,紫衣玉带为卿相,富贵荣华孰与同。', fortune: '上上' },
63: { text: '命主为官福禄长,得来富贵定非常,名题雁塔传金榜,定中高科天下扬。', fortune: '上上' },
64: { text: '此命生成福不轻,读书必定有功名,果然富贵前生定,一世荣华事事成。', fortune: '上上' },
65: { text: '细推此命福不轻,安国安邦极品人,文纷雕梁徽富贵,威声照耀四方闻。', fortune: '上上' },
66: { text: '此命推来福且宏,荣华富贵自然通,命中注定衣禄足,一世亨通稳且丰。', fortune: '上上' },
67: { text: '此命生来福自宏,田园家业最高隆,平生衣禄丰盈足,一世荣华万事通。', fortune: '上上' },
68: { text: '富贵荣华莫强求,强求不出反成羞,有福之人还自至,无福之人反成忧。', fortune: '中' },
69: { text: '君是人间前禄星,一生富贵众人钦,纵然福禄由天定,安享荣华过一生。', fortune: '上上' },
70: { text: '此命推来福禄宏,不须劳碌过平生,妻儿和顺皆如意,家道兴隆福自成。', fortune: '上' },
71: { text: '此命生来大不同,公侯卿相在其中,一生自有逍遥福,富贵荣华极品隆。', fortune: '上上' },
72: { text: '此命生来福泽长,兴家立业有祯祥,一生自有逍遥福,富贵荣华极品良。', fortune: '上上' },
};
/** Calculate Bone Weight Fortune from lunar calendar data */
export function calculateBoneWeight(
lunarYearGanzhiIndex: number, // 0-59 六十甲子 index
lunarMonth: number, // 1-12
lunarDay: number, // 1-30
earthBranchHourIndex: number, // 0-11 地支时辰 index
): BoneWeightResult {
const yearWt = YEAR_WEIGHTS[lunarYearGanzhiIndex] || 9;
const monthWt = MONTH_WEIGHTS[lunarMonth] || 9;
const dayWt = DAY_WEIGHTS[lunarDay] || 9;
const hourWt = HOUR_WEIGHTS[earthBranchHourIndex] || 9;
const totalWeight = yearWt + monthWt + dayWt + hourWt;
const totalLiang = Math.floor(totalWeight / 10);
const totalQian = totalWeight % 10;
// Find closest interpretation
const interpretation = findInterpretation(totalWeight);
return {
yearWeight: yearWt,
monthWeight: monthWt,
dayWeight: dayWt,
hourWeight: hourWt,
totalWeight,
totalLiang,
totalQian,
interpretation: interpretation.text,
fortune: interpretation.fortune as BoneWeightResult['fortune'],
};
}
function findInterpretation(totalQian: number): { text: string; fortune: string } {
// Direct match
if (INTERPRETATIONS[totalQian]) return INTERPRETATIONS[totalQian];
// Find closest
const keys = Object.keys(INTERPRETATIONS).map(Number).sort((a, b) => a - b);
let closest = keys[0];
let minDiff = Math.abs(totalQian - closest);
for (const k of keys) {
const diff = Math.abs(totalQian - k);
if (diff < minDiff) { minDiff = diff; closest = k; }
}
return INTERPRETATIONS[closest] || { text: '命格推来,自有天定', fortune: '中' };
}
@@ -0,0 +1,32 @@
/**
* 佛教节日(农历)— tyme4ts 不提供,这里维护常用重大佛教日期表
*/
const BUDDHIST_FESTIVALS: Record<string, string> = {
'1-1': '弥勒菩萨圣诞',
'2-8': '释迦牟尼佛出家日',
'2-15': '释迦牟尼佛涅槃日',
'2-19': '观音菩萨圣诞',
'2-21': '普贤菩萨圣诞',
'3-16': '准提菩萨圣诞',
'4-4': '文殊菩萨圣诞',
'4-8': '释迦牟尼佛圣诞(浴佛节)',
'5-3': '伽蓝菩萨圣诞',
'6-3': '韦驮菩萨圣诞',
'6-19': '观音菩萨成道日',
'7-13': '大势至菩萨圣诞',
'7-15': '盂兰盆节(佛欢喜日)',
'7-30': '地藏菩萨圣诞',
'8-22': '燃灯佛圣诞',
'9-19': '观音菩萨出家日',
'9-30': '药师佛圣诞',
'10-5': '达摩祖师诞辰',
'11-17': '阿弥陀佛圣诞',
'12-8': '释迦牟尼佛成道日(腊八)',
'12-29': '华严菩萨圣诞',
};
/** Get Buddhist festival name by lunar month/day (闰月不重复过节) */
export function getBuddhistFestival(lunarMonth: number, lunarDay: number): string | null {
return BUDDHIST_FESTIVALS[`${lunarMonth}-${lunarDay}`] || null;
}
@@ -0,0 +1,275 @@
import { SolarDay } from 'tyme4ts';
import type { EightCharInfo, PillarInfo } from '../types/bazi';
import type { DailyFortuneResult, PillarRelationship } from '../types/fortune';
import {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from './relationship';
const PILLAR_LABELS: Record<string, string> = {
year: '年柱', month: '月柱', day: '日柱', hour: '时柱',
};
const PILLAR_KEYS = ['year', 'month', 'day', 'hour'] as const;
export function calculateDailyFortune(userBazi: EightCharInfo, date: Date): DailyFortuneResult {
const solarDay = SolarDay.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
const lunarDay = solarDay.getLunarDay();
const lunarHour = lunarDay.getHours()[6];
const dayEightChar = lunarHour.getEightChar();
const dayPillars: Record<string, { ganzhi: string; stem: string; branch: string }> = {
year: { ganzhi: dayEightChar.getYear().getName(), stem: dayEightChar.getYear().getHeavenStem().getName(), branch: dayEightChar.getYear().getEarthBranch().getName() },
month: { ganzhi: dayEightChar.getMonth().getName(), stem: dayEightChar.getMonth().getHeavenStem().getName(), branch: dayEightChar.getMonth().getEarthBranch().getName() },
day: { ganzhi: dayEightChar.getDay().getName(), stem: dayEightChar.getDay().getHeavenStem().getName(), branch: dayEightChar.getDay().getEarthBranch().getName() },
hour: { ganzhi: dayEightChar.getHour().getName(), stem: dayEightChar.getHour().getHeavenStem().getName(), branch: dayEightChar.getHour().getEarthBranch().getName() },
};
const userPillars: Record<string, PillarInfo> = {
year: userBazi.yearPillar, month: userBazi.monthPillar, day: userBazi.dayPillar, hour: userBazi.hourPillar,
};
const relationships: PillarRelationship[] = [];
for (const key of PILLAR_KEYS) {
const userPillar = userPillars[key]; const dayP = dayPillars[key];
const stemTenStar = getTenStarRelationship(userPillar.heavenStem, dayP.stem);
const stemCombine = checkStemCombine(userPillar.heavenStem, dayP.stem);
const stemOpposite = checkStemOpposite(userPillar.heavenStem, dayP.stem);
const branchRel = getBranchRelationship(userPillar.earthBranch, dayP.branch);
let score = 0;
if (stemTenStar) {
const good = ['正印','偏印','食神','正财','偏财','正官'];
const bad = ['七杀','劫财','伤官'];
if (good.includes(stemTenStar)) score += 4;
else if (bad.includes(stemTenStar)) score -= 3;
else score += 1;
}
if (stemCombine) score += 5;
if (stemOpposite) score -= 6;
if (branchRel.combine) score += 4;
if (branchRel.threeCombine) score += 3;
if (branchRel.opposite) score -= 5;
if (branchRel.harm) score -= 4;
if (branchRel.punish) score -= 3;
score = Math.max(-10, Math.min(10, score));
relationships.push({
pillar: key, pillarLabel: PILLAR_LABELS[key],
userGanzhi: userPillar.ganzhi, dayGanzhi: dayP.ganzhi,
stemTenStar, stemCombine, stemOpposite,
branchCombine: branchRel.combine, branchThreeCombine: branchRel.threeCombine,
branchOpposite: branchRel.opposite, branchHarm: branchRel.harm,
branchPunish: branchRel.punish, branchFormation: branchRel.formation,
score,
});
}
const weightedScore =
relationships[0].score * 0.20 + relationships[1].score * 0.25 +
relationships[2].score * 0.40 + relationships[3].score * 0.15;
const overallScore = Math.max(-100, Math.min(100, Math.round(weightedScore * 10)));
const scoreLevel: DailyFortuneResult['scoreLevel'] =
overallScore >= 50 ? 'great' : overallScore >= 20 ? 'good' :
overallScore >= -20 ? 'fair' : overallScore >= -50 ? 'poor' : 'bad';
const luckyAspects = generateLuckyAspects(relationships);
const unluckyAspects = generateUnluckyAspects(relationships);
const suggestions = generateSuggestions(relationships, userBazi, overallScore);
const affectedAreas = determineAffectedAreas(relationships);
const luckyMeta = computeLuckyMeta(userBazi, dayPillars.day.ganzhi, overallScore);
const categoryScores = computeCategoryScores(relationships);
const lunarDateStr = `${lunarDay.getLunarMonth().getLunarYear().getYear()}年${lunarDay.getLunarMonth().getName()}${lunarDay.getName()}`;
return {
date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
lunarDate: lunarDateStr, dayGanzhi: dayPillars.day.ganzhi,
overallScore, scoreLevel, pillarRelationships: relationships,
luckyAspects, unluckyAspects, suggestions, affectedAreas, luckyMeta, categoryScores,
};
}
function generateLuckyAspects(relationships: PillarRelationship[]): string[] {
const seen = new Set<string>(); const aspects: string[] = [];
const tenStarPlain: Record<string, string> = {
'正官':'事业运不错,工作上容易得到认可和赏识',
'七杀':'进取心强,适合挑战难题和竞争性事务',
'正印':'学习运佳,适合读书充电或向长辈请教',
'偏印':'灵感丰富,适合研究和创意工作',
'食神':'心情舒畅,适合休闲放松和享受生活',
'伤官':'表达欲强,适合沟通交流和展示自我',
'正财':'财运平稳,适合处理日常财务和长期投资',
'偏财':'偏财运佳,可能有意外之财或投资收益',
'比肩':'人际关系和睦,容易得到朋友和同事的帮助',
'劫财':'社交活跃,适合参加聚会和团队活动',
};
const hasStemCombine = relationships.some(r => r.stemCombine);
const hasBranchCombine = relationships.some(r => r.branchCombine || r.branchThreeCombine);
for (const rel of relationships) {
if (rel.stemTenStar && tenStarPlain[rel.stemTenStar] && !seen.has(rel.stemTenStar)) {
aspects.push(tenStarPlain[rel.stemTenStar]); seen.add(rel.stemTenStar);
}
}
if (hasStemCombine && !seen.has('combine')) { aspects.push('天时地利人和,容易遇到贵人相助和好机会'); seen.add('combine'); }
if (hasBranchCombine && !seen.has('branch')) { aspects.push('身边关系和谐,适合与人合作或开展团队项目'); seen.add('branch'); }
if (aspects.length === 0) aspects.push('今天整体运势平稳,适合按部就班处理日常事务');
return aspects.slice(0, 4);
}
function generateUnluckyAspects(relationships: PillarRelationship[]): string[] {
const aspects: string[] = [];
const hasOpposite = relationships.some(r => r.branchOpposite);
const hasHarm = relationships.some(r => r.branchHarm);
const hasPunish = relationships.some(r => r.branchPunish);
const hasStemOpposite = relationships.some(r => r.stemOpposite);
const hasQiSha = relationships.some(r => r.stemTenStar === '七杀');
const hasJieCai = relationships.some(r => r.stemTenStar === '劫财');
const hasShangGuan = relationships.some(r => r.stemTenStar === '伤官');
if (hasStemOpposite) aspects.push('今天可能遇到突发变化或计划被打乱,保持冷静和弹性很重要');
if (hasOpposite) aspects.push('与他人意见不合的可能性较高,尽量避免争执和正面冲突');
if (hasHarm) aspects.push('留意身边的小人和是非,不要轻易透露自己的计划和秘密');
if (hasPunish) aspects.push('容易说错话或做出不恰当的举动,多听少说更安全');
if (hasQiSha) aspects.push('压力较大的一天,注意调节情绪,重要决策可暂缓');
if (hasJieCai) aspects.push('财务方面要谨慎,不宜大额消费或借钱给别人');
if (hasShangGuan) aspects.push('表达时注意方式方法,容易词不达意引起误会');
if (aspects.length === 0) aspects.push('今天没有什么特别需要注意的,保持平常心即可');
return aspects.slice(0, 3);
}
function generateSuggestions(
relationships: PillarRelationship[],
userBazi: EightCharInfo,
overallScore: number,
): string[] {
const s: string[] = [];
const de = userBazi.dayMasterElement;
const colors: Record<string,string> = { '木':'绿色/青色','火':'红色/紫色','土':'黄色/棕色','金':'白色/浅色','水':'蓝色/黑色' };
const dirs: Record<string,string> = { '木':'东方','火':'南方','土':'中央','金':'西方','水':'北方' };
const hasConflict = relationships.some(r => r.branchOpposite || r.stemOpposite);
const hasCombine = relationships.some(r => r.branchCombine || r.stemCombine);
const hasHarm = relationships.some(r => r.branchHarm);
const hasPunish = relationships.some(r => r.branchPunish);
const goodPool = [
de ? `👔 幸运色${colors[de]||'浅色'},穿对颜色运气更好` : `👔 今天穿浅色衣服心情更好`,
`✅ 适合推进重要事项,果断决策会有好结果`,
`📝 把计划写下来按部就班执行,效率翻倍`,
`🎯 专注一件事比多线作战更有效果`,
`🤝 适合约重要的人见面,贵人运在线`,
`🌅 早起一点早晨的效率最高`,
`📊 适合总结和复盘能发现新机会`,
`🏃 运动或户外活动对运势有加成`,
];
const fairPool = [
`💡 运势平稳该做什么就做什么`,
`☕ 适合处理日常事务不宜做重大改变`,
`📖 适合学习充电多读多听少说`,
`🧹 整理收纳会让心情变好`,
`🍵 节奏放慢一点稳扎稳打`,
`📞 适合和老朋友聊聊天`,
];
const poorPool = [
`🧘 适合保守行事多观察多思考`,
`☕ 适合独处和复盘少社交`,
`🛡️ 以守成为主避免冲动`,
`📿 冥想或听音乐能化解负面情绪`,
`🙏 遇到不顺心的事深呼吸三秒`,
`📝 把想法写下来明天再行动`,
];
const badPool = [
`🛡️ 宜静不宜动重要决策能缓则缓`,
`🚫 避免签合同或做出重大承诺`,
`🧘 适合冥想休息养精蓄锐`,
`📿 去清净处走走转换气场`,
`💤 早点休息好睡眠是最好的转运`,
`🙏 好事多磨心态会好很多`,
];
let pool = fairPool;
if (overallScore >= 30) pool = goodPool;
else if (overallScore >= 0) pool = fairPool;
else if (overallScore >= -30) pool = poorPool;
else pool = badPool;
const seed = new Date().getDate() + new Date().getMonth() * 31;
s.push(pool[seed % pool.length]);
s.push(pool[(seed + 5) % pool.length]);
if (overallScore >= 10 && de && dirs[de]) s.push(`🧭 向${dirs[de]}行事有意外好运`);
if (hasCombine && !hasConflict) s.push('🤝 人际关系活跃适合合作洽谈');
else if (hasConflict && hasHarm) s.push('⚠️ 人际关系有暗礁保持微笑少说话');
else if (hasConflict) s.push('🙏 意见不合时先认同再引导');
else if (hasPunish) s.push('💬 说话前过遍脑子今天容易说错话');
const seen = new Set<string>();
const unique = s.filter(t => { if (seen.has(t)) return false; seen.add(t); return true; });
return unique.slice(0, 4);
}
function computeLuckyMeta(userBazi: EightCharInfo, dayGanzhi: string, score: number) {
const de = userBazi.dayMasterElement;
const colorMap: Record<string, string[]> = { '木': ['绿色','青色','翠绿'], '火': ['红色','紫色','粉色'], '土': ['黄色','棕色','米色'], '金': ['白色','银色','浅灰'], '水': ['蓝色','黑色','深灰'] };
const dirMap: Record<string, string> = { '木': '东方', '火': '南方', '土': '中央', '金': '西方', '水': '北方' };
const actMap: Record<string, string[]> = {
'木': ['户外散步','园艺','阅读','写作'], '火': ['社交','演讲','创意','运动'], '土': ['整理收纳','理财','烹饪','冥想'],
'金': ['商务洽谈','签约','购物','美容'], '水': ['学习进修','旅行','游泳','听音乐'],
};
const colors = colorMap[de] || ['红色','白色'];
const direction = dirMap[de] || '东方';
const seed = parseInt(dayGanzhi.charCodeAt(0).toString()) + new Date().getDate();
const n1 = (seed % 9) + 1;
const n2 = ((seed * 3 + 7) % 9) + 1;
const activities = actMap[de] || ['运动','阅读'];
const activity = activities[seed % activities.length];
return {
colors: [colors[0], colors[1]],
numbers: [n1, n2],
direction,
element: de,
activity: score >= 0 ? activity : activities[(seed + 3) % activities.length],
};
}
function computeCategoryScores(relationships: PillarRelationship[]) {
const tenStarLove: Record<string,number> = { '正官':8,'七杀':-2,'正印':3,'偏印':2,'食神':6,'伤官':5,'正财':4,'偏财':3 };
const tenStarCareer: Record<string,number> = { '正官':10,'七杀':8,'正印':5,'偏印':4,'食神':3,'伤官':2,'正财':2 };
const tenStarWealth: Record<string,number> = { '正财':10,'偏财':8,'食神':6,'伤官':4,'正官':2,'七杀':-2 };
const tenStarHealth: Record<string,number> = { '正印':8,'偏印':6,'比肩':5,'食神':4,'七杀':-4,'伤官':-2 };
let love=0, career=0, wealth=0, health=0;
for (const rel of relationships) {
const ts = rel.stemTenStar;
if (!ts) continue;
const w = rel.pillar === 'day' ? 3 : rel.pillar === 'month' ? 2 : 1;
love += (tenStarLove[ts]||0) * w + (rel.branchHarm?-5:0) * w + (rel.branchCombine?4:0) * w;
career += (tenStarCareer[ts]||0) * w + (rel.branchOpposite?-4:0) * w + (rel.branchThreeCombine?5:0) * w;
wealth += (tenStarWealth[ts]||0) * w + (rel.stemCombine?5:0) * w;
health += (tenStarHealth[ts]||0) * w + (rel.branchOpposite?-3:0) * w;
}
// Normalize to -100..100
const clamp = (v:number) => Math.max(-100, Math.min(100, Math.round(v * 2.5)));
return { love: clamp(love), career: clamp(career), wealth: clamp(wealth), health: clamp(health) };
}
function determineAffectedAreas(relationships: PillarRelationship[]): string[] {
const areas = new Set<string>();
const areaMapping: Record<string, string> = {
'正财':'财运','偏财':'偏财/投资','正官':'事业','七杀':'事业/压力',
'正印':'学业','偏印':'学业/智慧','食神':'创作/享乐','伤官':'口才/表达',
'比肩':'人际关系','劫财':'竞争/人际',
};
for (const rel of relationships) {
if (rel.stemTenStar && areaMapping[rel.stemTenStar]) areas.add(areaMapping[rel.stemTenStar]);
if (rel.branchOpposite && rel.pillar === 'year') areas.add('长辈/根基');
if (rel.branchOpposite && rel.pillar === 'month') areas.add('事业/家庭');
if (rel.branchOpposite && rel.pillar === 'day') areas.add('婚姻/健康');
if (rel.branchOpposite && rel.pillar === 'hour') areas.add('子女/晚年');
}
return areas.size > 0 ? [...areas] : ['综合运势'];
}
@@ -0,0 +1,99 @@
import type { EightCharInfo } from '../types/bazi';
/** Five Element profile */
export interface ElementProfile {
wood: number;
fire: number;
earth: number;
metal: number;
water: number;
total: number;
dominant: string;
weakest: string;
isBalanced: boolean;
}
/** Analyze the five element balance in a Bazi chart */
export function analyzeElementBalance(bazi: EightCharInfo): ElementProfile {
const pillars = [bazi.yearPillar, bazi.monthPillar, bazi.dayPillar, bazi.hourPillar];
const counts: Record<string, number> = {
'木': 0,
'火': 0,
'土': 0,
'金': 0,
'水': 0,
};
for (const pillar of pillars) {
// Count stem element
if (pillar.elementStem && counts[pillar.elementStem] !== undefined) {
counts[pillar.elementStem] += 1;
}
// Count branch element
if (pillar.elementBranch && counts[pillar.elementBranch] !== undefined) {
counts[pillar.elementBranch] += 1;
}
// Count hidden stem elements (half weight)
for (const hs of pillar.hideStems) {
// Infer element from stem name
const element = inferElementFromStem(hs.stem);
if (element && counts[element] !== undefined) {
counts[element] += 0.5;
}
}
}
const total = counts['木'] + counts['火'] + counts['土'] + counts['金'] + counts['水'];
// Find dominant and weakest
let dominant = '木';
let weakest = '木';
let maxCount = 0;
let minCount = Infinity;
for (const [element, count] of Object.entries(counts)) {
if (count > maxCount) {
maxCount = count;
dominant = element;
}
if (count < minCount) {
minCount = count;
weakest = element;
}
}
// Balance check: each element should be within 30% of ideal (20% each)
const ideal = total / 5;
let isBalanced = true;
for (const count of Object.values(counts)) {
if (Math.abs(count - ideal) > ideal * 0.4) {
isBalanced = false;
break;
}
}
return {
wood: counts['木'] || 0,
fire: counts['火'] || 0,
earth: counts['土'] || 0,
metal: counts['金'] || 0,
water: counts['水'] || 0,
total,
dominant,
weakest,
isBalanced,
};
}
/** Infer five element from heavenly stem name */
function inferElementFromStem(stem: string): string | null {
const stemElements: Record<string, string> = {
'甲': '木', '乙': '木',
'丙': '火', '丁': '火',
'戊': '土', '己': '土',
'庚': '金', '辛': '金',
'壬': '水', '癸': '水',
};
return stemElements[stem] || null;
}
@@ -0,0 +1,88 @@
/**
* 大运/流年/流月/流日 与日主的生克冲合分析
* 将任意干支与日主(日干/日支)比较,输出十神、五行生克、天干合冲、地支关系与简化吉凶。
*/
import { getTenStarRelationship, checkStemCombine, checkStemOpposite, getBranchRelationship } from './relationship';
export interface FortuneLuck {
ganzhi: string;
tenStar: string | null;
/** 该柱五行 vs 日主五行:比和/生我/我生/克我/我克 */
elementRelation: string;
stemCombine: boolean;
stemOpposite: boolean;
branchCombine: boolean;
branchThreeCombine: boolean;
branchOpposite: boolean;
branchHarm: boolean;
branchPunish: boolean;
score: number;
level: '吉' | '凶' | '平';
}
const STEM_ELEMENT: Record<string, string> = {
'甲': '木', '乙': '木', '丙': '火', '丁': '火', '戊': '土',
'己': '土', '庚': '金', '辛': '金', '壬': '水', '癸': '水',
};
const GENERATES: Record<string, string> = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' };
const KILLS: Record<string, string> = { '木': '土', '土': '水', '水': '火', '火': '金', '金': '木' };
const GOOD_TEN_STARS = ['正印', '偏印', '食神', '正财', '偏财', '正官'];
const BAD_TEN_STARS = ['七杀', '劫财', '伤官'];
/** Analyze an external ganzhi (大运/流年/流月/流日) against the day master pillar */
export function analyzeFortuneGanzhi(
ganzhi: string,
dayStem: string,
dayBranch: string,
): FortuneLuck {
const stem = ganzhi[0];
const branch = ganzhi[1];
const tenStar = getTenStarRelationship(dayStem, stem);
const el = STEM_ELEMENT[stem];
const de = STEM_ELEMENT[dayStem];
let elementRelation: string;
if (!el || !de || el === de) elementRelation = '比和';
else if (GENERATES[el] === de) elementRelation = '生我';
else if (GENERATES[de] === el) elementRelation = '我生';
else if (KILLS[el] === de) elementRelation = '克我';
else elementRelation = '我克';
const stemCombine = checkStemCombine(stem, dayStem);
const stemOpposite = checkStemOpposite(stem, dayStem);
const br = getBranchRelationship(branch, dayBranch);
let score = 0;
if (tenStar) {
if (GOOD_TEN_STARS.includes(tenStar)) score += 4;
else if (BAD_TEN_STARS.includes(tenStar)) score -= 3;
else score += 1;
}
if (stemCombine) score += 5;
if (stemOpposite) score -= 6;
if (br.combine) score += 4;
if (br.threeCombine) score += 3;
if (br.opposite) score -= 5;
if (br.harm) score -= 4;
if (br.punish) score -= 3;
score = Math.max(-10, Math.min(10, score));
const level: FortuneLuck['level'] = score >= 3 ? '吉' : score <= -3 ? '凶' : '平';
return {
ganzhi,
tenStar,
elementRelation,
stemCombine,
stemOpposite,
branchCombine: br.combine,
branchThreeCombine: br.threeCombine,
branchOpposite: br.opposite,
branchHarm: br.harm,
branchPunish: br.punish,
score,
level,
};
}
@@ -0,0 +1,258 @@
/**
* 梅花易数 (Plum Blossom I-Ching Divination)
* Based on time (year, month, day, hour) to derive hexagrams
*/
export interface TrigramInfo {
index: number; // 1-8 (乾兑离震巽坎艮坤)
name: string; // Chinese name
symbol: string; // Unicode trigram symbol
element: string; // Five element
direction: string; // Direction
nature: string; // Natural phenomenon
trait: string; // Personality trait
body: string; // Body part
}
export interface HexagramInfo {
number: number; // 1-64
name: string; // Chinese name e.g. "乾为天"
upperTrigram: TrigramInfo;
lowerTrigram: TrigramInfo;
changingLine: number; // 1-6, which line changes
interpretation: string; // Overall interpretation
judgment: string; // 彖辞
image: string; // 象辞
lines: string[]; // 6 line interpretations
}
export interface PlumBlossomResult {
originalHexagram: HexagramInfo;
transformedHexagram: HexagramInfo | null;
mutualHexagram: HexagramInfo | null;
upperTrigram: TrigramInfo;
lowerTrigram: TrigramInfo;
changingLine: number;
constitution: string; // 体卦
function: string; // 用卦
relationship: string; // 体用关系
}
// 8 Trigrams (八卦)
const TRIGRAMS: Record<number, TrigramInfo> = {
1: { index: 1, name: '乾', symbol: '☰', element: '金', direction: '西北', nature: '天', trait: '健', body: '首' },
2: { index: 2, name: '兑', symbol: '☱', element: '金', direction: '西', nature: '泽', trait: '悦', body: '口' },
3: { index: 3, name: '离', symbol: '☲', element: '火', direction: '南', nature: '火', trait: '丽', body: '目' },
4: { index: 4, name: '震', symbol: '☳', element: '木', direction: '东', nature: '雷', trait: '动', body: '足' },
5: { index: 5, name: '巽', symbol: '☴', element: '木', direction: '东南', nature: '风', trait: '入', body: '股' },
6: { index: 6, name: '坎', symbol: '☵', element: '水', direction: '北', nature: '水', trait: '陷', body: '耳' },
7: { index: 7, name: '艮', symbol: '☶', element: '土', direction: '东北', nature: '山', trait: '止', body: '手' },
8: { index: 8, name: '坤', symbol: '☷', element: '土', direction: '西南', nature: '地', trait: '顺', body: '腹' },
};
// All 64 Hexagrams (complete I-Ching)
const H: Record<string, { name: string; judgment: string; image: string; lines: string[] }> = {
// 乾宫八卦 (1-8)
'1,1':{name:'乾为天',judgment:'大哉乾元,万物资始,乃统天。云行雨施,品物流形',image:'天行健,君子以自强不息',lines:['潜龙勿用','见龙在田,利见大人','君子终日乾乾,夕惕若厉','或跃在渊,无咎','飞龙在天,利见大人','亢龙有悔']},
'1,5':{name:'天风姤',judgment:'姤,遇也,柔遇刚也。天地相遇,品物咸章',image:'天下有风,姤。后以施命诰四方',lines:['系于金柅,贞吉','包有鱼,无咎','臀无肤,其行次且','包无鱼,起凶','以杞包瓜,含章','姤其角,吝']},
'1,7':{name:'天山遁',judgment:'遁亨,遁而亨也。刚当位而应,与时行也',image:'天下有山,遁。君子以远小人,不恶而严',lines:['遁尾厉,勿用有攸往','执之用黄牛之革','系遁,有疾厉','好遁,君子吉','嘉遁,贞吉','肥遁,无不利']},
'1,8':{name:'天地否',judgment:'否之匪人,不利君子贞。大往小来',image:'天地不交,否。君子以俭德辟难',lines:['拔茅茹,以其汇,贞吉','包承,小人吉,大人否','包羞','有命无咎,畴离祉','休否,大人吉','倾否,先否后喜']},
'5,8':{name:'风地观',judgment:'观,盥而不荐,有孚颙若。观天之神道而四时不忒',image:'风行地上,观。先王以省方观民设教',lines:['童观,小人无咎','窥观,利女贞','观我生进退','观国之光,利用宾于王','观我生,君子无咎','观其生,君子无咎']},
'7,8':{name:'山地剥',judgment:'剥,剥也,柔变刚也。不利有攸往',image:'山附于地,剥。上以厚下安宅',lines:['剥床以足,蔑贞凶','剥床以辨,蔑贞凶','剥之无咎','剥床以肤,凶','贯鱼以宫人宠,无不利','硕果不食,君子得舆']},
'3,8':{name:'火地晋',judgment:'晋,进也。明出地上,顺而丽乎大明',image:'明出地上,晋。君子以自昭明德',lines:['晋如摧如,贞吉','晋如愁如,贞吉','众允,悔亡','晋如鼫鼠,贞厉','悔亡,失得勿恤','晋其角,维用伐邑']},
'3,1':{name:'火天大有',judgment:'大有,柔得尊位,大中而上下应之',image:'火在天上,大有。君子以遏恶扬善,顺天休命',lines:['无交害,匪咎','大车以载,有攸往','公用亨于天子','匪其彭,无咎','厥孚交如,威如','自天佑之,吉无不利']},
// 坎宫八卦 (9-16)
'6,6':{name:'坎为水',judgment:'习坎,重险也。水流而不盈,行险而不失其信',image:'水洊至,习坎。君子以常德行习教事',lines:['习坎,入于坎窞','坎有险,求小得','来之坎坎,险且枕','樽酒簋贰,用缶','坎不盈,祗既平','系用徽纆,寘于丛棘']},
'6,2':{name:'水泽节',judgment:'节亨,苦节不可贞',image:'泽上有水,节。君子以制数度议德行',lines:['不出户庭,无咎','不出门庭,凶','不节若,则嗟若','安节,亨','甘节,吉','苦节,贞凶']},
'6,4':{name:'水雷屯',judgment:'屯,刚柔始交而难生。动乎险中,大亨贞',image:'云雷屯,君子以经纶',lines:['磐桓,利居贞','屯如邅如,乘马班如','即鹿无虞,惟入于林中','乘马班如,求婚媾','屯其膏,小贞吉','乘马班如,泣血涟如']},
'6,3':{name:'水火既济',judgment:'既济亨,小者亨也。利贞,初吉终乱',image:'水在火上,既济。君子以思患而豫防之',lines:['曳其轮,濡其尾','妇丧其茀,勿逐','高宗伐鬼方,三年克之','繻有衣袽,终日戒','东邻杀牛,不如西邻','濡其首,厉']},
'2,3':{name:'泽火革',judgment:'革,水火相息。天地革而四时成',image:'泽中有火,革。君子以治历明时',lines:['巩用黄牛之革','巳日乃革之,征吉','征凶,贞厉','悔亡,有孚改命','大人虎变,未占有孚','君子豹变,小人革面']},
'4,3':{name:'雷火丰',judgment:'丰,大也。明以动,故丰',image:'雷电皆至,丰。君子以折狱致刑',lines:['遇其配主,虽旬无咎','丰其蔀,日中见斗','丰其沛,日中见沬','丰其蔀,日中见斗','来章,有庆誉','丰其屋,蔀其家']},
'8,3':{name:'地火明夷',judgment:'明夷,利艰贞。明入地中,明夷',image:'明入地中,明夷。君子以莅众用晦而明',lines:['明夷于飞,垂其翼','明夷,夷于左股','明夷于南狩,得其大首','入于左腹,获明夷之心','箕子之明夷,利贞','不明晦,初登于天']},
'8,6':{name:'地水师',judgment:'师,众也。贞,丈人吉,无咎',image:'地中有水,师。君子以容民畜众',lines:['师出以律,否臧凶','在师中,吉无咎','师或舆尸,凶','师左次,无咎','田有禽,利执言','大君有命,开国承家']},
// 艮宫八卦 (17-24)
'7,7':{name:'艮为山',judgment:'艮,止也。时止则止,时行则行',image:'兼山,艮。君子以思不出其位',lines:['艮其趾,无咎','艮其腓,不拯其随','艮其限,列其夤','艮其身,无咎','艮其辅,言有序','敦艮,吉']},
'7,3':{name:'山火贲',judgment:'贲亨,柔来而文刚,故亨',image:'山下有火,贲。君子以明庶政无敢折狱',lines:['贲其趾,舍车而徒','贲其须','贲如濡如,永贞吉','贲如皤如,白马翰如','贲于丘园,束帛戋戋','白贲,无咎']},
'7,1':{name:'山天大畜',judgment:'大畜,刚健笃实辉光,日新其德',image:'天在山中,大畜。君子以多识前言往行',lines:['有厉,利已','舆说輹','良马逐,利艰贞','童牛之牿,元吉','豮豕之牙,吉','何天之衢,亨']},
'7,2':{name:'山泽损',judgment:'损,损下益上,其道上行',image:'山下有泽,损。君子以惩忿窒欲',lines:['已事遄往,无咎','利贞,征凶,弗损益之','三人行则损一人','损其疾,使遄有喜','或益之十朋之龟','弗损益之,无咎']},
'3,2':{name:'火泽睽',judgment:'睽,火动而上,泽动而下',image:'上火下泽,睽。君子以同而异',lines:['悔亡,丧马勿逐','遇主于巷,无咎','见舆曳,其牛掣','睽孤,遇元夫','悔亡,厥宗噬肤','睽孤,见豕负涂']},
'1,2':{name:'天泽履',judgment:'履,柔履刚也。说而应乎乾',image:'上天下泽,履。君子以辩上下定民志',lines:['素履往,无咎','履道坦坦,幽人贞吉','眇能视,跛能履','履虎尾,愬愬终吉','夬履,贞厉','视履考祥,其旋元吉']},
'5,2':{name:'风泽中孚',judgment:'中孚,柔在内而刚得中',image:'泽上有风,中孚。君子以议狱缓死',lines:['虞吉,有它不燕','鸣鹤在阴,其子和之','得敌,或鼓或罢','月几望,马匹亡','有孚挛如,无咎','翰音登于天,贞凶']},
'5,7':{name:'风山渐',judgment:'渐,女归吉也。进得位,往有功也',image:'山上有木,渐。君子以居贤德善俗',lines:['鸿渐于干,小子厉','鸿渐于磐,饮食衎衎','鸿渐于陆,夫征不复','鸿渐于木,或得其桷','鸿渐于陵,妇三岁不孕','鸿渐于逵,其羽可用为仪']},
// 震宫八卦 (25-32)
'4,4':{name:'震为雷',judgment:'震亨。震来虩虩,笑言哑哑,震惊百里',image:'洊雷,震。君子以恐惧修省',lines:['震来虩虩,后笑言哑哑','震来厉,亿丧贝','震苏苏,震行无眚','震遂泥','震往来厉,亿无丧','震索索,视矍矍']},
'4,8':{name:'雷地豫',judgment:'豫,刚应而志行,顺以动',image:'雷出地奋,豫。先王以作乐崇德',lines:['鸣豫,凶','介于石,不终日','盱豫悔,迟有悔','由豫,大有得','贞疾,恒不死','冥豫,成有渝']},
'4,6':{name:'雷水解',judgment:'解,险以动,动而免乎险',image:'雷雨作,解。君子以赦过宥罪',lines:['无咎','田获三狐,得黄矢','负且乘,致寇至','解而拇,朋至斯孚','君子维有解,吉','公用射隼于高墉之上']},
'4,5':{name:'雷风恒',judgment:'恒,久也。刚上而柔下',image:'雷风,恒。君子以立不易方',lines:['浚恒,贞凶','悔亡','不恒其德,或承之羞','田无禽','恒其德,贞妇人吉','振恒,凶']},
'8,5':{name:'地风升',judgment:'柔以时升,巽而顺,刚中而应',image:'地中生木,升。君子以顺德积小以高大',lines:['允升,大吉','孚乃利用禴,无咎','升虚邑','王用亨于岐山','贞吉,升阶','冥升,利于不息之贞']},
'6,5':{name:'水风井',judgment:'井,改邑不改井,无丧无得',image:'木上有水,井。君子以劳民劝相',lines:['井泥不食,旧井无禽','井谷射鲋,瓮敝漏','井渫不食,为我心恻','井甃,无咎','井洌,寒泉食','井收勿幕,有孚元吉']},
'2,5':{name:'泽风大过',judgment:'大过,大者过也。栋桡,本末弱也',image:'泽灭木,大过。君子以独立不惧遁世无闷',lines:['藉用白茅,无咎','枯杨生稊,老夫得其女妻','栋桡,凶','栋隆,吉','枯杨生华,老妇得士夫','过涉灭顶,凶']},
'2,4':{name:'泽雷随',judgment:'随,刚来而下柔,动而说',image:'泽中有雷,随。君子以向晦入宴息',lines:['官有渝,贞吉','系小子,失丈夫','系丈夫,失小子','随有获,贞凶','孚于嘉,吉','拘系之,乃从维之']},
// 巽宫八卦 (33-40)
'5,5':{name:'巽为风',judgment:'重巽以申命,刚巽乎中正而志行',image:'随风,巽。君子以申命行事',lines:['进退,利武人之贞','巽在床下,用史巫纷若','频巽,吝','悔亡,田获三品','贞吉,悔亡无不利','巽在床下,丧其资斧']},
'5,1':{name:'风天小畜',judgment:'小畜,柔得位而上下应之',image:'风行天上,小畜。君子以懿文德',lines:['复自道,何其咎','牵复,吉','舆说辐,夫妻反目','有孚,血去惕出','有孚挛如,富以其邻','既雨既处,尚德载']},
'5,3':{name:'风火家人',judgment:'家人,女正位乎内,男正位乎外',image:'风自火出,家人。君子以言有物而行有恒',lines:['闲有家,悔亡','无攸遂,在中馈','家人嗃嗃,悔厉吉','富家,大吉','王假有家,勿恤','有孚威如,终吉']},
'5,4':{name:'风雷益',judgment:'益,损上益下,民说无疆',image:'风雷,益。君子以见善则迁有过则改',lines:['利用为大作,元吉','或益之十朋之龟','益之用凶事,无咎','中行告公从,利用为依迁国','有孚惠心,勿问元吉','莫益之,或击之']},
'1,4':{name:'天雷无妄',judgment:'无妄,刚自外来而为主于内',image:'天下雷行,物与无妄。先王以茂对时育万物',lines:['无妄,往吉','不耕获,不菑畬','无妄之灾,或系之牛','可贞,无咎','无妄之疾,勿药有喜','无妄,行有眚']},
'3,4':{name:'火雷噬嗑',judgment:'噬嗑亨,利用狱。刚柔分动而明',image:'雷电噬嗑,先王以明罚敕法',lines:['屦校灭趾,无咎','噬肤灭鼻,无咎','噬腊肉,遇毒','噬干胏,得金矢','噬干肉,得黄金','何校灭耳,凶']},
'7,4':{name:'山雷颐',judgment:'颐,贞吉。观颐,自求口实',image:'山下有雷,颐。君子以慎言语节饮食',lines:['舍尔灵龟,观我朵颐','颠颐,拂经于丘颐','拂颐,贞凶','颠颐,吉','拂经,居贞吉','由颐,厉吉,利涉大川']},
'7,5':{name:'山风蛊',judgment:'蛊,元亨。利涉大川,先甲三日后甲三日',image:'山下有风,蛊。君子以振民育德',lines:['干父之蛊,有子考无咎','干母之蛊,不可贞','干父之蛊,小有悔','裕父之蛊,往见吝','干父之蛊,用誉','不事王侯,高尚其事']},
// 离宫八卦 (41-48)
'3,3':{name:'离为火',judgment:'离,丽也。日月丽乎天,百谷草木丽乎土',image:'明两作,离。大人以继明照于四方',lines:['履错然,敬之无咎','黄离,元吉','日昃之离,不鼓缶而歌','突如其来如,焚如死如弃如','出涕沱若,戚嗟若','王用出征,有嘉折首']},
'3,7':{name:'火山旅',judgment:'旅,小亨。旅贞吉',image:'山上有火,旅。君子以明慎用刑而不留狱',lines:['旅琐琐,斯其所取灾','旅即次,怀其资','旅焚其次,丧其童仆','旅于处,得其资斧','射雉,一矢亡','鸟焚其巢,旅人先笑后号咷']},
'3,5':{name:'火风鼎',judgment:'鼎,象也。以木巽火,亨饪也',image:'木上有火,鼎。君子以正位凝命',lines:['鼎颠趾,利出否','鼎有实,我仇有疾','鼎耳革,其行塞','鼎折足,覆公餗','鼎黄耳金铉,利贞','鼎玉铉,大吉']},
'3,6':{name:'火水未济',judgment:'未济亨,小狐汔济,濡其尾',image:'火在水上,未济。君子以慎辨物居方',lines:['濡其尾,吝','曳其轮,贞吉','未济,征凶','贞吉,悔亡','贞吉,无悔','有孚于饮酒,无咎']},
'7,6':{name:'山水蒙',judgment:'蒙亨。匪我求童蒙,童蒙求我',image:'山下出泉,蒙。君子以果行育德',lines:['发蒙,利用刑人','包蒙,吉','勿用取女,见金夫','困蒙,吝','童蒙,吉','击蒙,不利为寇']},
'5,6':{name:'风水涣',judgment:'涣亨。王假有庙,利涉大川',image:'风行水上,涣。先王以享于帝立庙',lines:['用拯马壮,吉','涣奔其机,悔亡','涣其躬,无悔','涣其群,元吉','涣汗其大号','涣其血,去逖出']},
'1,6':{name:'天水讼',judgment:'讼,上刚下险,险而健,讼',image:'天与水违行,讼。君子以作事谋始',lines:['不永所事,小有言','不克讼,归而逋','食旧德,贞厉终吉','不克讼,复即命渝','讼,元吉','或锡之鞶带,终朝三褫']},
'1,3':{name:'天火同人',judgment:'同人,柔得位得中而应乎乾',image:'天与火,同人。君子以类族辨物',lines:['同人于门,无咎','同人于宗,吝','伏戎于莽,升其高陵','乘其墉,弗克攻','同人先号咷而后笑','同人于郊,无悔']},
// 坤宫八卦 (49-56)
'8,8':{name:'坤为地',judgment:'至哉坤元,万物资生,乃顺承天',image:'地势坤,君子以厚德载物',lines:['履霜,坚冰至','直方大,不习无不利','含章可贞,或从王事','括囊,无咎无誉','黄裳,元吉','龙战于野,其血玄黄']},
'8,4':{name:'地雷复',judgment:'复亨。出入无疾,朋来无咎',image:'雷在地中,复。先王以至日闭关',lines:['不远复,无祗悔','休复,吉','频复,厉','中行独复','敦复,无悔','迷复,凶有灾眚']},
'8,2':{name:'地泽临',judgment:'临,刚浸而长,说而顺',image:'泽上有地,临。君子以教思无穷容保民无疆',lines:['咸临,贞吉','咸临,吉无不利','甘临,无攸利','至临,无咎','知临,大君之宜','敦临,吉无咎']},
'8,1':{name:'地天泰',judgment:'泰,小往大来,吉亨。天地交而万物通',image:'天地交,泰。后以财成天地之道',lines:['拔茅茹,以其汇','包荒,用冯河','无平不陂,无往不复','翩翩,不富以其邻','帝乙归妹,以祉元吉','城复于隍,勿用师']},
'4,1':{name:'雷天大壮',judgment:'大壮,大者壮也。刚以动,故壮',image:'雷在天上,大壮。君子以非礼勿履',lines:['壮于趾,征凶','贞吉','小人用壮,君子用罔','贞吉,悔亡','丧羊于易,无悔','羝羊触藩,不能退']},
'2,1':{name:'泽天夬',judgment:'夬,决也,刚决柔也。健而说,决而和',image:'泽上于天,夬。君子以施禄及下',lines:['壮于前趾,往不胜为咎','惕号,莫夜有戎','壮于頄,有凶','臀无肤,其行次且','苋陆夬夬,中行无咎','无号,终有凶']},
'6,1':{name:'水天需',judgment:'需,须也。险在前也,刚健而不陷',image:'云上于天,需。君子以饮食宴乐',lines:['需于郊,利用恒','需于沙,小有言','需于泥,致寇至','需于血,出自穴','需于酒食,贞吉','入于穴,有不速之客三人来']},
'6,8':{name:'水地比',judgment:'比,吉也。比,辅也,下顺从也',image:'地上有水,比。先王以建万国亲诸侯',lines:['有孚比之,无咎','比之自内,贞吉','比之匪人','外比之,贞吉','显比,王用三驱','比之无首,凶']},
// 兑宫八卦 (57-64)
'2,2':{name:'兑为泽',judgment:'兑,说也。刚中而柔外,说以利贞',image:'丽泽,兑。君子以朋友讲习',lines:['和兑,吉','孚兑,吉','来兑,凶','商兑未宁,介疾有喜','孚于剥,有厉','引兑']},
'2,6':{name:'泽水困',judgment:'困,刚掩也。险以说,困而不失其所',image:'泽无水,困。君子以致命遂志',lines:['臀困于株木,入于幽谷','困于酒食,朱绂方来','困于石,据于蒺藜','来徐徐,困于金车','劓刖,困于赤绂','困于葛藟,于臲兀']},
'2,8':{name:'泽地萃',judgment:'萃,聚也。顺以说,刚中而应',image:'泽上于地,萃。君子以除戎器戒不虞',lines:['有孚不终,乃乱乃萃','引吉,无咎','萃如嗟如,无攸利','大吉,无咎','萃有位,无咎','赍咨涕洟,无咎']},
'2,7':{name:'泽山咸',judgment:'咸,感也。柔上而刚下,二气感应以相与',image:'山上有泽,咸。君子以虚受人',lines:['咸其拇','咸其腓,凶','咸其股,执其随','贞吉,悔亡','咸其脢,无悔','咸其辅颊舌']},
'6,7':{name:'水山蹇',judgment:'蹇,难也,险在前也。见险而能止',image:'山上有水,蹇。君子以反身修德',lines:['往蹇,来誉','王臣蹇蹇,匪躬之故','往蹇,来反','往蹇,来连','大蹇,朋来','往蹇,来硕']},
'8,7':{name:'地山谦',judgment:'谦亨。天道下济而光明,地道卑而上行',image:'地中有山,谦。君子以裒多益寡称物平施',lines:['谦谦君子,用涉大川','鸣谦,贞吉','劳谦君子,有终吉','无不利,撝谦','不富以其邻,利用侵伐','鸣谦,利用行师']},
'4,7':{name:'雷山小过',judgment:'小过,小者过而亨也。过以利贞',image:'山上有雷,小过。君子以行过乎恭',lines:['飞鸟以凶','过其祖,遇其妣','弗过防之,从或戕之','无咎,弗过遇之','密云不雨,自我西郊','弗遇过之,飞鸟离之']},
'4,2':{name:'雷泽归妹',judgment:'归妹,天地之大义也。天地不交而万物不兴',image:'泽上有雷,归妹。君子以永终知敝',lines:['归妹以娣,跛能履','眇能视,利幽人之贞','归妹以须,反归以娣','归妹愆期,迟归有时','帝乙归妹,其君之袂','女承筐无实,士刲羊无血']},
};
// Generate all 64 hexagrams
function buildHexagramMap() {
const map: Record<string, HexagramInfo> = {};
for (let upper = 1; upper <= 8; upper++) {
for (let lower = 1; lower <= 8; lower++) {
const key = `${upper},${lower}`;
const hName = H[key];
if (hName) {
map[key] = {
number: (upper - 1) * 8 + lower,
name: hName.name,
upperTrigram: TRIGRAMS[upper],
lowerTrigram: TRIGRAMS[lower],
changingLine: 0,
interpretation: hName.judgment,
judgment: hName.judgment,
image: hName.image,
lines: hName.lines,
};
}
}
}
return map;
}
const HEXAGRAM_MAP = buildHexagramMap();
/** Calculate Plum Blossom I-Ching from date and time */
export function calculatePlumBlossom(
year: number,
month: number,
day: number,
hour: number = 12,
): PlumBlossomResult {
// Use full numbers for more entropy
const yearNum = year;
const monthNum = month;
const dayNum = day;
const hourIndex = Math.floor(((hour + 1) % 24) / 2); // 0-11 地支时辰
// Standard Plum Blossom formula:
// Upper trigram: (year + month + day) % 8 → 0-7 → +1 → 1-8
const upperIdx = ((yearNum + monthNum + dayNum) % 8) + 1;
// Lower trigram: (month + day + hourIndex + 1) % 8 + 1
const lowerIdx = ((monthNum + dayNum + hourIndex + 1) % 8) + 1;
// Changing line: (year + month + day + hourIndex + 1) % 6 + 1
const changingLine = ((yearNum + monthNum + dayNum + hourIndex + 1) % 6) + 1;
const upperTrigram = TRIGRAMS[upperIdx];
const lowerTrigram = TRIGRAMS[lowerIdx];
// Original hexagram
const originalKey = `${upperIdx},${lowerIdx}`;
const originalHexagram = HEXAGRAM_MAP[originalKey] || createFallbackHexagram(upperIdx, lowerIdx, changingLine);
// Transformed hexagram (after changing line)
let transUpperIdx = upperIdx;
let transLowerIdx = lowerIdx;
// Changing line 1-3 affects lower trigram, 4-6 affects upper
if (changingLine >= 4) {
transUpperIdx = flipTrigramLine(upperIdx, changingLine - 3);
} else {
transLowerIdx = flipTrigramLine(lowerIdx, changingLine);
}
const transKey = `${transUpperIdx},${transLowerIdx}`;
const transformedHexagram = HEXAGRAM_MAP[transKey] || createFallbackHexagram(transUpperIdx, transLowerIdx, 0);
// Mutual hexagram (互卦): 2-4 lines → lower, 3-5 lines → upper
// Simplified: use middle two trigrams
const mutualUpperIdx = lowerIdx; // simplified
const mutualLowerIdx = upperIdx; // simplified
const mutualKey = `${mutualUpperIdx},${mutualLowerIdx}`;
const mutualHexagram = HEXAGRAM_MAP[mutualKey] || null;
// Constitution (体卦) and Function (用卦)
// 体卦 = lower trigram, 用卦 = upper trigram
const constitution = lowerTrigram.element;
const function_ = upperTrigram.element;
const relationship = getElementRelationship(constitution, function_);
return {
originalHexagram: { ...originalHexagram, changingLine },
transformedHexagram,
mutualHexagram,
upperTrigram,
lowerTrigram,
changingLine,
constitution,
function: function_,
relationship,
};
}
function flipTrigramLine(trigramIdx: number, line: number): number {
// Flip a single line of the trigram
// Trigrams encoded as bits: 乾111=7, 兑110=6, 离101=5, 震100=4, 巽011=3, 坎010=2, 艮001=1, 坤000=0
const encoding = [0, 7, 6, 5, 4, 3, 2, 1, 0]; // index → bit pattern
let bits = encoding[trigramIdx] || 0;
bits ^= (1 << (line - 1)); // flip the line
// Map back
const decoding = [8, 7, 6, 2, 5, 3, 4, 1]; // bit pattern → index
return decoding[bits] || trigramIdx;
}
function getElementRelationship(body: string, func: string): string {
const cycle: Record<string, string> = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' };
const reverse: Record<string, string> = { '木': '水', '水': '金', '金': '土', '土': '火', '火': '木' };
if (body === func) return '比和(体用相同,诸事顺利)';
if (cycle[body] === func) return '体生用(泄气,宜守不宜攻)';
if (reverse[body] === func) return '用生体(得力,有贵人相助)';
if (cycle[func] === body) return '用克体(受制,诸事不顺)';
if (reverse[func] === body) return '体克用(主动,需付出努力)';
return '体用相生';
}
function createFallbackHexagram(upper: number, lower: number, changingLine: number): HexagramInfo {
const ut = TRIGRAMS[upper];
const lt = TRIGRAMS[lower];
return {
number: (upper - 1) * 8 + lower,
name: `${ut.name}${lt.name}`,
upperTrigram: ut,
lowerTrigram: lt,
changingLine,
interpretation: '此卦象需结合具体事理参详',
judgment: '',
image: '',
lines: ['','','','','',''],
};
}
@@ -0,0 +1,116 @@
import {
HeavenStem,
EarthBranch,
} from 'tyme4ts';
/** Relationship classification for branch interactions */
export interface BranchRelationship {
combine: boolean; // 六合
threeCombine: boolean; // 三合
opposite: boolean; // 六冲
harm: boolean; // 六害
punish: boolean; // 相刑
formation: string | null; // 三合局名称
}
/** Three-combine formations (三合局) */
const THREE_COMBINES: Record<string, { branches: string[]; name: string; element: string }> = {
'水局': { branches: ['申', '子', '辰'], name: '水局', element: '水' },
'木局': { branches: ['亥', '卯', '未'], name: '木局', element: '木' },
'火局': { branches: ['寅', '午', '戌'], name: '火局', element: '火' },
'金局': { branches: ['巳', '酉', '丑'], name: '金局', element: '金' },
};
/** Check branch relationships between two earth branches */
export function getBranchRelationship(branchA: string, branchB: string): BranchRelationship {
const a = EarthBranch.fromName(branchA);
const b = EarthBranch.fromName(branchB);
// Six combine (六合): each branch's combine partner
const combinePartner = a.getCombine();
const hasCombine = combinePartner ? combinePartner.getName() === b.getName() : false;
// Opposite (六冲): each branch's opposite
const oppositePartner = a.getOpposite();
const hasOpposite = oppositePartner ? oppositePartner.getName() === b.getName() : false;
// Harm (六害): each branch's harm partner
let hasHarm = false;
try {
const harmPartner = a.getHarm();
hasHarm = harmPartner ? harmPartner.getName() === b.getName() : false;
} catch { /* harm may not be available */ }
// Three combine (三合): check if both branches are in the same formation
let hasThreeCombine = false;
let formation: string | null = null;
for (const [name, info] of Object.entries(THREE_COMBINES)) {
if (info.branches.includes(branchA) && info.branches.includes(branchB)) {
hasThreeCombine = true;
formation = name;
break;
}
}
// Punish (相刑)
const hasPunish = checkPunish(branchA, branchB);
return {
combine: hasCombine,
threeCombine: hasThreeCombine,
opposite: hasOpposite,
harm: hasHarm,
punish: hasPunish,
formation,
};
}
/** Check for punishment relationship between two branches */
function checkPunish(a: string, b: string): boolean {
// Self punishment (自刑)
const selfPunish = ['辰', '午', '酉', '亥'];
if (a === b && selfPunish.includes(a)) return true;
// Classic punishment pairs
const punishPairs: [string, string][] = [
['寅', '巳'], ['巳', '申'], ['申', '寅'], // 无恩之刑
['丑', '戌'], ['戌', '未'], ['未', '丑'], // 恃势之刑
['子', '卯'], ['卯', '子'], // 无礼之刑
];
return punishPairs.some(([x, y]) => x === a && y === b);
}
/** Get Ten Star relationship between two heavenly stems */
export function getTenStarRelationship(subjectStem: string, objectStem: string): string {
try {
const s = HeavenStem.fromName(subjectStem);
const o = HeavenStem.fromName(objectStem);
return s.getTenStar(o).getName();
} catch {
return '';
}
}
/** Check if two heavenly stems combine (天干合) */
export function checkStemCombine(stemA: string, stemB: string): boolean {
const combinePairs: Record<string, string> = {
'甲': '己', '己': '甲',
'乙': '庚', '庚': '乙',
'丙': '辛', '辛': '丙',
'丁': '壬', '壬': '丁',
'戊': '癸', '癸': '戊',
};
return combinePairs[stemA] === stemB;
}
/** Check if two heavenly stems oppose (天干冲) */
export function checkStemOpposite(stemA: string, stemB: string): boolean {
const oppositePairs: Record<string, string> = {
'甲': '庚', '庚': '甲',
'乙': '辛', '辛': '乙',
'丙': '壬', '壬': '丙',
'丁': '癸', '癸': '丁',
};
return oppositePairs[stemA] === stemB;
}
@@ -0,0 +1,361 @@
/**
* 八字神煞 (Shen Sha)
* 神煞是固定规则查表 + 四柱匹配的标记,规则来源《三命通会》《渊海子平》。
* 注意:部分神煞网上存在版本差异,本模块采用主流排盘工具通行版本。
*/
import type { EightCharInfo, PillarInfo } from '../types/bazi';
export interface ShenshaInfo {
name: string;
/** 吉/凶/中性 */
type: '吉' | '凶' | '中性';
/** 分类:贵人/文星/财禄/感情/变动/威权/灾煞/孤独/格局 */
category: string;
/** 命中查法说明,如 "日干己查四支" */
anchors: string[];
/** 命中位置,如 ["年支","时支"] */
foundIn: string[];
/** 吉凶含义简述 */
description: string;
}
interface ShenshaRule {
name: string;
type: ShenshaInfo['type'];
category: string;
description: string;
/** 按天干(年干/日干)查四支 */
byStem?: Record<string, string[]>;
stemAnchors?: Array<'yearStem' | 'dayStem'>;
/** 按地支(年支/日支)查四支 */
byBranch?: Record<string, string[]>;
/** 按月支查四干 */
byMonthStem?: Record<string, string[]>;
/** 日柱特殊格局 */
byDayPillar?: string[];
}
const PILLAR_KEYS = ['year', 'month', 'day', 'hour'] as const;
const RULES: ShenshaRule[] = [
{
name: '天乙贵人',
type: '吉',
category: '贵人',
description: '最吉之神煞,主逢凶化吉、贵人相助',
stemAnchors: ['dayStem'],
byStem: {
'甲': ['丑', '未'], '戊': ['丑', '未'], '庚': ['丑', '未'],
'乙': ['子', '申'], '己': ['子', '申'],
'丙': ['亥', '酉'], '丁': ['亥', '酉'],
'壬': ['卯', '巳'], '癸': ['卯', '巳'],
'辛': ['寅', '午'],
},
},
{
name: '天厨贵人',
type: '吉',
category: '贵人',
description: '主口福与衣食之禄,衣食无忧',
byStem: {
'甲': ['巳'], '乙': ['午'], '丙': ['巳'], '丁': ['午'],
'戊': ['申'], '己': ['酉'], '庚': ['亥'], '辛': ['子'],
'壬': ['寅'], '癸': ['卯'],
},
},
{
name: '文昌贵人',
type: '吉',
category: '文星',
description: '主聪明好学、利科名学业',
byStem: {
'甲': ['巳'], '乙': ['午'], '丙': ['申'], '丁': ['酉'],
'戊': ['申'], '己': ['酉'], '庚': ['亥'], '辛': ['子'],
'壬': ['寅'], '癸': ['卯'],
},
},
{
name: '禄神',
type: '吉',
category: '财禄',
description: '日干之禄,主衣禄与财运根基',
byStem: {
'甲': ['寅'], '乙': ['卯'], '丙': ['巳'], '丁': ['午'],
'戊': ['巳'], '己': ['午'], '庚': ['申'], '辛': ['酉'],
'壬': ['亥'], '癸': ['子'],
},
},
{
name: '羊刃',
type: '凶',
category: '灾煞',
description: '刚烈冲动之星,主脾气刚猛,喜用则有权柄',
byStem: {
'甲': ['卯'], '乙': ['寅'], '丙': ['午'], '丁': ['巳'],
'戊': ['午'], '己': ['巳'], '庚': ['酉'], '辛': ['申'],
'壬': ['子'], '癸': ['亥'],
},
},
{
name: '金舆',
type: '吉',
category: '财禄',
description: '禄前二位,主婚恋顺遂、富贵安逸',
byStem: {
'甲': ['辰'], '乙': ['巳'], '丙': ['未'], '丁': ['申'],
'戊': ['未'], '己': ['申'], '庚': ['戌'], '辛': ['亥'],
'壬': ['丑'], '癸': ['寅'],
},
},
{
name: '天德贵人',
type: '吉',
category: '贵人',
description: '主心地仁慈、逢凶化吉,利化解灾厄',
byMonthStem: {
'寅': ['丁'], '卯': ['申'], '辰': ['壬'], '巳': ['辛'],
'午': ['亥'], '未': ['甲'], '申': ['癸'], '酉': ['寅'],
'戌': ['丙'], '亥': ['乙'], '子': ['巳'], '丑': ['庚'],
},
},
{
name: '月德贵人',
type: '吉',
category: '贵人',
description: '主福荫深厚、遇难呈祥',
byMonthStem: {
'寅': ['丙'], '午': ['丙'], '戌': ['丙'],
'申': ['壬'], '子': ['壬'], '辰': ['壬'],
'亥': ['甲'], '卯': ['甲'], '未': ['甲'],
'巳': ['庚'], '酉': ['庚'], '丑': ['庚'],
},
},
{
name: '桃花(咸池)',
type: '中性',
category: '感情',
description: '主异性缘、魅力与风流,利艺术才华',
byBranch: {
'申': ['酉'], '子': ['酉'], '辰': ['酉'],
'寅': ['卯'], '午': ['卯'], '戌': ['卯'],
'巳': ['午'], '酉': ['午'], '丑': ['午'],
'亥': ['子'], '卯': ['子'], '未': ['子'],
},
},
{
name: '驿马',
type: '中性',
category: '变动',
description: '主奔波变动、外出发展,动中得财',
byBranch: {
'申': ['寅'], '子': ['寅'], '辰': ['寅'],
'寅': ['申'], '午': ['申'], '戌': ['申'],
'巳': ['亥'], '酉': ['亥'], '丑': ['亥'],
'亥': ['巳'], '卯': ['巳'], '未': ['巳'],
},
},
{
name: '华盖',
type: '中性',
category: '孤独',
description: '主艺术天赋、悟性高,也主清高孤傲',
byBranch: {
'申': ['辰'], '子': ['辰'], '辰': ['辰'],
'寅': ['戌'], '午': ['戌'], '戌': ['戌'],
'巳': ['丑'], '酉': ['丑'], '丑': ['丑'],
'亥': ['未'], '卯': ['未'], '未': ['未'],
},
},
{
name: '劫煞',
type: '凶',
category: '灾煞',
description: '主突发变故、是非破财,宜谨慎',
byBranch: {
'申': ['巳'], '子': ['巳'], '辰': ['巳'],
'寅': ['亥'], '午': ['亥'], '戌': ['亥'],
'巳': ['寅'], '酉': ['寅'], '丑': ['寅'],
'亥': ['申'], '卯': ['申'], '未': ['申'],
},
},
{
name: '亡神',
type: '凶',
category: '灾煞',
description: '主心机深、谋略强,亦主口舌是非',
byBranch: {
'申': ['亥'], '子': ['亥'], '辰': ['亥'],
'寅': ['巳'], '午': ['巳'], '戌': ['巳'],
'巳': ['申'], '酉': ['申'], '丑': ['申'],
'亥': ['寅'], '卯': ['寅'], '未': ['寅'],
},
},
{
name: '将星',
type: '吉',
category: '威权',
description: '主领导才能与威严,掌权柄之星',
byBranch: {
'申': ['子'], '子': ['子'], '辰': ['子'],
'寅': ['午'], '午': ['午'], '戌': ['午'],
'巳': ['酉'], '酉': ['酉'], '丑': ['酉'],
'亥': ['卯'], '卯': ['卯'], '未': ['卯'],
},
},
{
name: '红鸾',
type: '吉',
category: '感情',
description: '主婚恋喜事、姻缘早成',
byBranch: {
'子': ['卯'], '丑': ['寅'], '寅': ['丑'], '卯': ['子'],
'辰': ['亥'], '巳': ['戌'], '午': ['酉'], '未': ['申'],
'申': ['未'], '酉': ['午'], '戌': ['巳'], '亥': ['辰'],
},
},
{
name: '天喜',
type: '吉',
category: '感情',
description: '红鸾对宫,主喜事临门、人缘佳',
byBranch: {
'子': ['酉'], '丑': ['申'], '寅': ['未'], '卯': ['午'],
'辰': ['巳'], '巳': ['辰'], '午': ['卯'], '未': ['寅'],
'申': ['丑'], '酉': ['子'], '戌': ['亥'], '亥': ['戌'],
},
},
{
name: '孤辰',
type: '凶',
category: '孤独',
description: '主孤独离群、六亲缘薄',
byBranch: {
'亥': ['寅'], '子': ['寅'], '丑': ['寅'],
'寅': ['巳'], '卯': ['巳'], '辰': ['巳'],
'巳': ['申'], '午': ['申'], '未': ['申'],
'申': ['亥'], '酉': ['亥'], '戌': ['亥'],
},
},
{
name: '寡宿',
type: '凶',
category: '孤独',
description: '主孤寡之象,婚恋宜晚',
byBranch: {
'亥': ['戌'], '子': ['戌'], '丑': ['戌'],
'寅': ['丑'], '卯': ['丑'], '辰': ['丑'],
'巳': ['辰'], '午': ['辰'], '未': ['辰'],
'申': ['未'], '酉': ['未'], '戌': ['未'],
},
},
{
name: '天罗',
type: '凶',
category: '灾煞',
description: '戌亥为天罗,主困顿束缚,男命尤忌',
byBranch: { '子': ['戌', '亥'], '丑': ['戌', '亥'], '寅': ['戌', '亥'], '卯': ['戌', '亥'], '辰': ['戌', '亥'], '巳': ['戌', '亥'], '午': ['戌', '亥'], '未': ['戌', '亥'], '申': ['戌', '亥'], '酉': ['戌', '亥'], '戌': ['戌', '亥'], '亥': ['戌', '亥'] },
},
{
name: '地网',
type: '凶',
category: '灾煞',
description: '辰巳为地网,主束缚波折,女命尤忌',
byBranch: { '子': ['辰', '巳'], '丑': ['辰', '巳'], '寅': ['辰', '巳'], '卯': ['辰', '巳'], '辰': ['辰', '巳'], '巳': ['辰', '巳'], '午': ['辰', '巳'], '未': ['辰', '巳'], '申': ['辰', '巳'], '酉': ['辰', '巳'], '戌': ['辰', '巳'], '亥': ['辰', '巳'] },
},
{
name: '魁罡',
type: '中性',
category: '格局',
description: '日柱魁罡,主刚毅果断、聪明果敢,忌刑冲',
byDayPillar: ['庚辰', '庚戌', '壬辰', '戊戌'],
},
{
name: '阴差阳错',
type: '凶',
category: '格局',
description: '日柱阴差阳错,主婚姻不顺、易生波折',
byDayPillar: ['丙子', '丁丑', '戊寅', '辛卯', '壬辰', '癸巳', '丙午', '丁未', '戊申', '辛酉', '壬戌', '癸亥'],
},
];
const PILLAR_LABELS: Record<string, string> = {
yearStem: '年干', monthStem: '月干', dayStem: '日干', hourStem: '时干',
yearBranch: '年支', monthBranch: '月支', dayBranch: '日支', hourBranch: '时支',
};
/** Analyze Shen Sha presence in a Bazi chart */
export function analyzeShensha(bazi: EightCharInfo): ShenshaInfo[] {
const pillars: Record<string, PillarInfo> = {
year: bazi.yearPillar, month: bazi.monthPillar, day: bazi.dayPillar, hour: bazi.hourPillar,
};
const stems: Record<string, string> = {};
const branches: Record<string, string> = {};
for (const k of PILLAR_KEYS) {
stems[`${k}Stem`] = pillars[k].heavenStem;
branches[`${k}Branch`] = pillars[k].earthBranch;
}
const dayGanzhi = pillars.day.ganzhi;
const result: ShenshaInfo[] = [];
for (const rule of RULES) {
const anchors: string[] = [];
const foundIn = new Set<string>();
if (rule.byStem) {
// 按规则指定的天干锚点(默认年干、日干)查四支
for (const anchorKey of rule.stemAnchors ?? ['yearStem', 'dayStem'] as const) {
const targets = rule.byStem[stems[anchorKey]];
if (!targets) continue;
const hits = PILLAR_KEYS.filter(k => targets.includes(branches[`${k}Branch`]));
if (hits.length > 0) {
anchors.push(PILLAR_LABELS[anchorKey]);
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Branch`]));
}
}
}
if (rule.byBranch) {
// 年支、日支查四支
for (const anchorKey of ['yearBranch', 'dayBranch'] as const) {
const targets = rule.byBranch[branches[anchorKey]];
if (!targets) continue;
const hits = PILLAR_KEYS.filter(k => targets.includes(branches[`${k}Branch`]));
if (hits.length > 0) {
anchors.push(PILLAR_LABELS[anchorKey]);
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Branch`]));
}
}
}
if (rule.byMonthStem) {
// 月支查四干
const targets = rule.byMonthStem[branches.monthBranch];
if (targets) {
const hits = PILLAR_KEYS.filter(k => targets.includes(stems[`${k}Stem`]));
if (hits.length > 0) {
anchors.push('月支');
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Stem`]));
}
}
}
if (rule.byDayPillar && rule.byDayPillar.includes(dayGanzhi)) {
anchors.push('日柱');
foundIn.add('日柱');
}
if (foundIn.size > 0) {
result.push({
name: rule.name,
type: rule.type,
category: rule.category,
anchors,
foundIn: [...foundIn],
description: rule.description,
});
}
}
return result;
}
+84
View File
@@ -0,0 +1,84 @@
// Types
export type {
DayInfo,
AlmanacInfo,
HourAlmanac,
PillarInfo,
HideStemInfo,
EightCharInfo,
DecadeFortuneInfo,
FortuneInfo,
ChildLimitInfo,
BaziFullResult,
PillarRelationship,
DailyFortuneResult,
} from './types';
// Transformers
export {
solarDayToDayInfo,
getMonthCalendar,
getDayInfo,
getTodayInfo,
} from './transformers/day';
export {
solarDayToAlmanacInfo,
getAlmanacInfo,
} from './transformers/almanac';
export {
birthInfoToBazi,
} from './transformers/bazi';
export type { BirthParams } from './transformers/bazi';
export {
getYearMonths,
} from './transformers/yearMonths';
export type { YearMonthInfo } from './transformers/yearMonths';
// Calculators
export {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from './calculators/relationship';
export type { BranchRelationship } from './calculators/relationship';
export {
calculateDailyFortune,
} from './calculators/dailyMatch';
export {
analyzeElementBalance,
} from './calculators/elementStrength';
export type { ElementProfile } from './calculators/elementStrength';
export {
calculatePlumBlossom,
} from './calculators/plumBlossom';
export type {
TrigramInfo,
HexagramInfo,
PlumBlossomResult,
} from './calculators/plumBlossom';
export {
calculateBoneWeight,
} from './calculators/boneWeight';
export type { BoneWeightResult } from './calculators/boneWeight';
export {
getBuddhistFestival,
} from './calculators/buddhistDates';
export {
analyzeShensha,
} from './calculators/shensha';
export type { ShenshaInfo } from './calculators/shensha';
export {
analyzeFortuneGanzhi,
} from './calculators/fortuneLuck';
export type { FortuneLuck } from './calculators/fortuneLuck';
@@ -0,0 +1,186 @@
import {
SolarDay,
type LunarHour,
} from 'tyme4ts';
import type { AlmanacInfo, HourAlmanac } from '../types/almanac';
/** Transform a SolarDay into a full AlmanacInfo object */
export function solarDayToAlmanacInfo(solarDay: SolarDay): AlmanacInfo {
const lunarDay = solarDay.getLunarDay();
const sixtyCycle = lunarDay.getSixtyCycle();
const stem = sixtyCycle.getHeavenStem();
const branch = sixtyCycle.getEarthBranch();
// Duty officer
const duty = lunarDay.getDuty();
// Twelve star
const twelveStar = lunarDay.getTwelveStar();
const ecliptic = twelveStar.getEcliptic();
// Twenty-eight star
const twentyEightStar = lunarDay.getTwentyEightStar();
// Nine star
const nineStar = lunarDay.getNineStar();
// Six star
const sixStar = lunarDay.getSixStar ? lunarDay.getSixStar().getName() : '';
// Minor Ren
const minorRen = lunarDay.getMinorRen ? lunarDay.getMinorRen() : null;
// Fetus
const fetusDay = lunarDay.getFetusDay();
// Recommendations and avoidances
let recommends: string[] = [];
let avoids: string[] = [];
try {
recommends = lunarDay.getRecommends().map(r => r.getName());
avoids = lunarDay.getAvoids().map(a => a.getName());
} catch { /* may throw for some dates */ }
// Gods
const goodGods: string[] = [];
const badGods: string[] = [];
try {
const gods = lunarDay.getGods();
for (const god of gods) {
const luck = god.getLuck();
if (luck) {
if (luck.getName() === '吉') {
goodGods.push(god.getName());
} else {
badGods.push(god.getName());
}
}
}
} catch { /* may throw */ }
// Peng Zu taboos
const pengZu = sixtyCycle.getPengZu();
// Branch relationships
const opposite = branch.getOpposite();
const harm = branch.getHarm();
const combine = branch.getCombine();
// Evil direction
const ominous = branch.getOminous ? branch.getOminous() : null;
// Na Yin
const nayin = sixtyCycle.getSound().getName();
// Moon phase
let phase = '';
try {
const p = lunarDay.getPhase();
if (p) phase = p.getName();
} catch { /* no phase info */ }
// Hourly almanac
const hourDetails = buildHourlyAlmanac(lunarDay.getHours(), recommends, avoids);
// Determine duty luck
const dutyName = duty.getName();
const luckyDuties = ['除', '执', '危', '成', '开'];
const unluckyDuties = ['建', '满', '平', '破', '收', '闭'];
let dutyLuck: 'good' | 'bad' | 'neutral' = 'neutral';
if (luckyDuties.includes(dutyName)) dutyLuck = 'good';
else if (unluckyDuties.includes(dutyName)) dutyLuck = 'bad';
// Note: duty luck also depends on day branch; simplified here
return {
duty: dutyName,
dutyLuck,
twelveStar: {
name: twelveStar.getName(),
ecliptic: ecliptic ? ecliptic.getName() : '',
luck: ecliptic && ecliptic.getName() === '黄道' ? 'good' : 'bad',
},
twentyEightStar: {
name: twentyEightStar.getName(),
luck: twentyEightStar.getLuck()?.getName() === '吉' ? 'good' : 'bad',
animal: twentyEightStar.getAnimal()?.getName() || '',
},
nineStar: {
name: nineStar.getName(),
color: nineStar.getColor ? nineStar.getColor() : '',
element: nineStar.getElement ? nineStar.getElement().getName() : '',
},
sixStar,
minorRen: minorRen ? {
name: minorRen.getName(),
luck: minorRen.getLuck()?.getName() === '吉' ? 'good' : 'bad',
element: minorRen.getElement()?.getName() || '',
} : { name: '', luck: 'bad' as const, element: '' },
phase,
fetus: {
direction: fetusDay.getDirection()?.getName() || '',
side: fetusDay.getSide() !== undefined ? (fetusDay.getSide() as unknown as number === 0 ? '房内' : '房外') : '',
position: fetusDay.getName(),
},
recommends,
avoids,
goodGods,
badGods,
dayStem: stem.getName(),
dayBranch: branch.getName(),
dayGanzhi: sixtyCycle.getName(),
pengZu: pengZu.getName(),
pengZuStem: pengZu.getPengZuHeavenStem()?.getName() || '',
pengZuBranch: pengZu.getPengZuEarthBranch()?.getName() || '',
clash: `${opposite.getZodiac().getName()}(${opposite.getName()})`,
harm: harm ? `${harm.getZodiac().getName()}(${harm.getName()})` : '',
combine: combine ? `${combine.getZodiac().getName()}(${combine.getName()})` : '',
evilDirection: ominous ? ominous.getName() : '',
nayin,
hourDetails,
};
}
function buildHourlyAlmanac(hours: LunarHour[], _dayRecommends: string[], _dayAvoids: string[]): HourAlmanac[] {
return hours.map(hour => {
const hourSixtyCycle = hour.getSixtyCycle();
const twelveStar = hour.getTwelveStar ? hour.getTwelveStar() : null;
const nineStar = hour.getNineStar ? hour.getNineStar() : null;
let hourRecommends: string[] = [];
let hourAvoids: string[] = [];
try {
hourRecommends = hour.getRecommends().map(r => r.getName());
hourAvoids = hour.getAvoids().map(a => a.getName());
} catch { /* may throw */ }
const branchName = hourSixtyCycle.getEarthBranch().getName();
const hourNames: Record<string, string> = {
'子': '子时', '丑': '丑时', '寅': '寅时', '卯': '卯时',
'辰': '辰时', '巳': '巳时', '午': '午时', '未': '未时',
'申': '申时', '酉': '酉时', '戌': '戌时', '亥': '亥时',
};
const hourRanges: Record<string, string> = {
'子': '23:00-01:00', '丑': '01:00-03:00', '寅': '03:00-05:00',
'卯': '05:00-07:00', '辰': '07:00-09:00', '巳': '09:00-11:00',
'午': '11:00-13:00', '未': '13:00-15:00', '申': '15:00-17:00',
'酉': '17:00-19:00', '戌': '19:00-21:00', '亥': '21:00-23:00',
};
return {
branch: branchName,
name: hourNames[branchName] || branchName,
range: hourRanges[branchName] || '',
ganzhi: hourSixtyCycle.getName(),
recommends: hourRecommends,
avoids: hourAvoids,
twelveStar: twelveStar?.getName() || '',
nineStar: nineStar?.getName() || '',
};
});
}
/** Get AlmanacInfo for a specific date */
export function getAlmanacInfo(year: number, month: number, day: number): AlmanacInfo {
const solarDay = SolarDay.fromYmd(year, month, day);
return solarDayToAlmanacInfo(solarDay);
}
+241
View File
@@ -0,0 +1,241 @@
import {
SolarTime,
Gender,
AbstractChildLimitProvider,
ChildLimit,
HideHeavenStemType,
type SolarTerm,
YinYang,
type SixtyCycle,
type HeavenStem,
} from 'tyme4ts';
import type {
PillarInfo,
HideStemInfo,
DecadeFortuneInfo,
FortuneInfo,
BaziFullResult,
} from '../types/bazi';
export interface BirthParams {
year: number;
month: number;
day: number;
hour: number;
minute: number;
gender: 'male' | 'female';
/** 八字流派:lateZiNextDay=晚子时算次日(23点换日,默认);earlyZiSameDay=晚子时算当日(0点换日) */
ziSect?: 'lateZiNextDay' | 'earlyZiSameDay';
}
class PaipanChildLimitProvider extends AbstractChildLimitProvider {
getInfo(birthTime: SolarTime, term: SolarTerm) {
const termTime = term.getJulianDay().getSolarTime();
const endTime = birthTime.isAfter(termTime) ? birthTime : termTime;
const startTime = birthTime.isAfter(termTime) ? termTime : birthTime;
const startDate = Date.UTC(startTime.getYear(), startTime.getMonth() - 1, startTime.getDay());
const endDate = Date.UTC(endTime.getYear(), endTime.getMonth() - 1, endTime.getDay());
let dayDiff = Math.round((endDate - startDate) / 86_400_000);
let minuteDiff = (endTime.getHour() * 60 + endTime.getMinute()) - (startTime.getHour() * 60 + startTime.getMinute());
if (minuteDiff < 0) {
minuteDiff += 1_440;
dayDiff -= 1;
}
let minutes = dayDiff * 1_440 + minuteDiff;
const year = Math.floor(minutes / 4_320);
minutes -= year * 4_320;
const month = Math.floor(minutes / 360);
minutes -= month * 360;
const day = Math.floor(minutes / 12);
minutes -= day * 12;
const hour = minutes * 2;
return this.next(birthTime, year, month, day, hour, 0, 0);
}
}
/** Transform birth parameters into full Bazi result */
export function birthInfoToBazi(params: BirthParams): BaziFullResult {
const { year, month, day, hour, minute, gender, ziSect = 'lateZiNextDay' } = params;
const solarTime = SolarTime.fromYmdHms(year, month, day, hour, minute, 0);
// 早子时流派:23:00 后出生按当日早子时排四柱(日柱不换日),起运仍按真实出生时间
const ziHour =
ziSect === 'earlyZiSameDay' && hour >= 23
? SolarTime.fromYmdHms(year, month, day, 0, minute, 0)
: solarTime;
const lunarHour = ziHour.getLunarHour();
const eightChar = lunarHour.getEightChar();
const yearPillar = eightChar.getYear();
const monthPillar = eightChar.getMonth();
const dayPillar = eightChar.getDay();
const hourPillar = eightChar.getHour();
// Day master
const dayStem = dayPillar.getHeavenStem();
const dayMasterStem = dayStem.getName();
const dayMasterElement = dayStem.getElement().getName();
// Extract pillars with Ten Star relative to day master
const yearPillarInfo = extractPillarInfo(yearPillar, dayStem);
const monthPillarInfo = extractPillarInfo(monthPillar, dayStem);
const dayPillarInfo = extractPillarInfo(dayPillar, dayStem);
const hourPillarInfo = extractPillarInfo(hourPillar, dayStem);
// Fetal origin, fetal breath, own sign, body sign
const fetalOrigin = eightChar.getFetalOrigin();
const fetalBreath = eightChar.getFetalBreath();
const ownSign = eightChar.getOwnSign();
const bodySign = eightChar.getBodySign();
// Empty branches
const emptyTen = dayPillar.getTen();
const emptyBranches = emptyTen ? dayPillar.getExtraEarthBranches().map(b => b.getName()) : [];
// Child limit
ChildLimit.provider = new PaipanChildLimitProvider();
const genderEnum = gender === 'male' ? Gender.MAN : Gender.WOMAN;
const childLimit = ChildLimit.fromSolarTime(solarTime, genderEnum);
const startDecade = childLimit.getStartDecadeFortune();
// Decade fortunes (大运) — 10 decades
const decadeFortunes: DecadeFortuneInfo[] = [];
let currentDecade = startDecade;
for (let i = 0; i < 10 && currentDecade; i++) {
const sc = currentDecade.getSixtyCycle();
const decadeAnnualFortunes: FortuneInfo[] = [];
let currentFortune = currentDecade.getStartFortune();
for (let j = 0; j < 10 && currentFortune; j++) {
const fsc = currentFortune.getSixtyCycle();
decadeAnnualFortunes.push({
age: currentFortune.getAge(),
year: currentFortune.getLunarYear().getYear(),
ganzhi: fsc.getName(),
nayin: fsc.getSound().getName(),
});
currentFortune = currentFortune.next(1) as typeof currentFortune;
}
decadeFortunes.push({
index: i,
ganzhi: sc.getName(),
startAge: currentDecade.getStartAge(),
endAge: currentDecade.getEndAge(),
startYear: currentDecade.getStartLunarYear().getYear(),
endYear: currentDecade.getEndLunarYear().getYear(),
heavenStem: sc.getHeavenStem().getName(),
earthBranch: sc.getEarthBranch().getName(),
nayin: sc.getSound().getName(),
annualFortunes: decadeAnnualFortunes,
});
currentDecade = currentDecade.next(1) as typeof currentDecade;
}
const annualFortunes: FortuneInfo[] = decadeFortunes[0]?.annualFortunes ?? [];
return {
eightChar: {
birthDate: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
birthTime: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
gender,
yearPillar: yearPillarInfo,
monthPillar: monthPillarInfo,
dayPillar: dayPillarInfo,
hourPillar: hourPillarInfo,
dayMaster: `${dayMasterStem}${dayMasterElement}`,
dayMasterStem,
dayMasterElement,
fetalOrigin: fetalOrigin.getName(),
fetalBreath: fetalBreath.getName(),
ownSign: ownSign.getName(),
bodySign: bodySign.getName(),
emptyBranches,
emptyTen: emptyTen ? emptyTen.getName() : '',
},
childLimit: {
birthTime: childLimit.getStartTime().toString(),
startTime: childLimit.getEndTime().toString(),
endTime: childLimit.getEndTime().toString(),
yearCount: childLimit.getYearCount(),
monthCount: childLimit.getMonthCount(),
dayCount: childLimit.getDayCount(),
hourCount: childLimit.getHourCount(),
minuteCount: childLimit.getMinuteCount(),
forward: childLimit.isForward(),
startAge: childLimit.getStartAge(),
endAge: childLimit.getEndAge(),
},
decadeFortunes,
annualFortunes,
};
}
/** Extract pillar info from a SixtyCycle, with Ten Star relative to day master */
function extractPillarInfo(sixtyCycle: SixtyCycle, dayMasterStem: HeavenStem): PillarInfo {
const stem = sixtyCycle.getHeavenStem();
const branch = sixtyCycle.getEarthBranch();
const sound = sixtyCycle.getSound();
// Hidden stems
const hideStems: HideStemInfo[] = [];
try {
const allHideStems = branch.getHideHeavenStems();
if (allHideStems) {
for (const hs of allHideStems) {
let typeName = '';
const type = hs.getType();
if (type === HideHeavenStemType.MAIN) typeName = '本气';
else if (type === HideHeavenStemType.MIDDLE) typeName = '中气';
else if (type === HideHeavenStemType.RESIDUAL) typeName = '余气';
let tenStarName: string | null = null;
try {
tenStarName = hs.getHeavenStem().getTenStar(dayMasterStem).getName();
} catch { /* ten star may not be available */ }
hideStems.push({
stem: hs.getHeavenStem().getName(),
type: typeName,
tenStar: tenStarName,
});
}
}
} catch { /* hide stems may not be available */ }
// Terrain (十二长生)
let terrainName = '';
let terrainFortune: 'good' | 'bad' | 'neutral' = 'neutral';
try {
const terrain = stem.getTerrain(branch);
terrainName = terrain.getName();
const goodTerrain = ['长生', '冠带', '临官', '帝旺', '胎', '养'];
const badTerrain = ['死', '墓', '绝'];
if (goodTerrain.includes(terrainName)) terrainFortune = 'good';
else if (badTerrain.includes(terrainName)) terrainFortune = 'bad';
} catch { /* terrain might fail */ }
// Ten Star
let tenStarName: string | null = null;
try {
tenStarName = stem.getTenStar(dayMasterStem).getName();
} catch { /* ten star may not be available */ }
const yinYang = stem.getYinYang();
const branchYinYang = branch.getYinYang();
return {
ganzhi: sixtyCycle.getName(),
heavenStem: stem.getName(),
earthBranch: branch.getName(),
elementStem: stem.getElement().getName(),
elementBranch: branch.getElement().getName(),
yinYangStem: yinYang === YinYang.YANG ? 'yang' : 'yin',
yinYangBranch: branchYinYang === YinYang.YANG ? 'yang' : 'yin',
hideStems,
nayin: sound.getName(),
terrain: {
name: terrainName,
fortune: terrainFortune,
},
tenStar: tenStarName,
};
}
+201
View File
@@ -0,0 +1,201 @@
import {
SolarDay,
SolarMonth,
} from 'tyme4ts';
import type { DayInfo } from '../types/calendar';
import { getBuddhistFestival } from '../calculators/buddhistDates';
const SEASON_BY_MONTH: Record<number, string> = {
3: '春季', 4: '春季', 5: '春季',
6: '夏季', 7: '夏季', 8: '夏季',
9: '秋季', 10: '秋季', 11: '秋季',
12: '冬季', 1: '冬季', 2: '冬季',
};
/** Transform a SolarDay into a plain DayInfo object */
export function solarDayToDayInfo(solarDay: SolarDay): DayInfo {
const lunarDay = solarDay.getLunarDay();
const lunarMonth = lunarDay.getLunarMonth();
const week = solarDay.getWeek();
const constellation = solarDay.getConstellation();
const term = solarDay.getTerm();
const termDay = solarDay.getTermDay();
const month = solarDay.getSolarMonth().getMonth();
const solarYear = solarDay.getSolarMonth().getSolarYear().getYear();
// Solar term
const isTermDay = termDay !== null;
const solarTerm = isTermDay ? term.getName() : null;
let solarTermTime: string | null = null;
if (isTermDay) {
try {
const jd = term.getJulianDay();
if (jd) {
const st = jd.getSolarTime();
solarTermTime = `${String(st.getHour()).padStart(2,'0')}:${String(st.getMinute()).padStart(2,'0')}`;
}
} catch { /* time not available */ }
}
// Season + term progress (every day belongs to a solar term)
const season = SEASON_BY_MONTH[month] || '';
let currentSolarTerm: string | null = null;
let termDayIndex: number | null = null;
let nextSolarTerm: string | null = null;
let daysToNextTerm: number | null = null;
try {
const currentTerm = solarDay.getTerm();
const nextTerm = currentTerm.next(1);
currentSolarTerm = currentTerm.getName();
termDayIndex = solarDay.subtract(currentTerm.getSolarDay()) + 1;
nextSolarTerm = nextTerm.getName();
daysToNextTerm = nextTerm.getSolarDay().subtract(solarDay);
} catch { /* term may fail */ }
// Julian day, Buddhist era, Islamic/Hijri date
let julianDay: number | null = null;
try { julianDay = solarDay.getJulianDay().getDay(); } catch { /* */ }
const buddhistYear = solarYear + 543;
let hijriDate: string | null = null;
try {
const hd = solarDay.getHijriDay();
const hm = hd.getHijriMonth();
hijriDate = `${hm.getHijriYear().getYear()}年${String(hm.getIndexInYear()).padStart(2, '0')}月${String(hd.getDay()).padStart(2, '0')}日`;
} catch { /* */ }
const buddhistFestival = getBuddhistFestival(lunarMonth.getMonth(), lunarDay.getDay());
// Phenology
let phenology: string | null = null;
try {
const pd = solarDay.getPhenologyDay();
if (pd) phenology = pd.getPhenology().getName();
} catch { /* not available for all dates */ }
// Dog days
let dogDay: string | null = null;
try {
const dd = solarDay.getDogDay();
if (dd) dogDay = dd.getDog().getName();
} catch { /* not in dog days */ }
// Nine-day cold
let nineDay: string | null = null;
try {
const nd = solarDay.getNineDay();
if (nd) nineDay = nd.getNine().getName();
} catch { /* not in nine days */ }
// Moon phase
let moonPhase: string | null = null;
try {
const phase = solarDay.getPhase();
if (phase) moonPhase = phase.getName();
} catch { /* not available */ }
// Festivals
let lunarFestival: string | null = null;
try {
const lf = lunarDay.getFestival();
if (lf) lunarFestival = lf.getName();
} catch { /* no festival */ }
let solarFestival: string | null = null;
try {
const sf = solarDay.getFestival();
if (sf) solarFestival = sf.getName();
} catch { /* no festival */ }
let legalHoliday: { name: string; isWork: boolean } | null = null;
try {
const lh = solarDay.getLegalHoliday();
if (lh) legalHoliday = { name: lh.getName(), isWork: lh.isWork() };
} catch { /* not a legal holiday */ }
// Gan-Zhi
const daySixtyCycle = lunarDay.getSixtyCycle();
const yearSixtyCycle = lunarDay.getYearSixtyCycle();
const monthSixtyCycle = lunarDay.getMonthSixtyCycle();
// Today check
const now = new Date();
const isToday =
solarDay.getSolarMonth().getSolarYear().getYear() === now.getFullYear() &&
solarDay.getSolarMonth().getMonth() === now.getMonth() + 1 &&
solarDay.getDay() === now.getDate();
const weekDayIndex = week.getIndex();
const isWeekend = weekDayIndex === 0 || weekDayIndex === 6;
return {
solarDate: `${solarDay.getSolarMonth().getSolarYear().getYear()}-${String(solarDay.getSolarMonth().getMonth()).padStart(2, '0')}-${String(solarDay.getDay()).padStart(2, '0')}`,
solarDay: solarDay.getDay(),
solarMonth: solarDay.getSolarMonth().getMonth(),
solarYear: solarDay.getSolarMonth().getSolarYear().getYear(),
weekDay: week.getName(),
weekDayIndex,
constellation: constellation.getName(),
solarTerm,
solarTermTime,
isTermDay,
currentSolarTerm,
season,
termDayIndex,
nextSolarTerm,
daysToNextTerm,
julianDay,
buddhistYear,
hijriDate,
buddhistFestival,
phenology,
dogDay,
nineDay,
lunarYear: lunarMonth.getLunarYear().getYear(),
lunarMonth: lunarMonth.getMonth(),
lunarMonthName: lunarMonth.getName(),
lunarDay: lunarDay.getDay(),
lunarDayName: lunarDay.getName(),
isLeapMonth: lunarMonth.isLeap(),
lunarYearGanzhi: yearSixtyCycle.getName(),
lunarMonthGanzhi: monthSixtyCycle.getName(),
lunarDayGanzhi: daySixtyCycle.getName(),
zodiac: daySixtyCycle.getEarthBranch().getZodiac().getName(),
lunarFestival,
solarFestival,
legalHoliday,
moonPhase,
isToday,
isWeekend,
dayOfWeek: weekDayIndex,
};
}
/** Get calendar days for a month as a 2D array (weeks × days) */
export function getMonthCalendar(year: number, month: number, weekStart: 0 | 1 = 0): DayInfo[][] {
const solarMonth = SolarMonth.fromYm(year, month);
const weekCount = solarMonth.getWeekCount(weekStart);
const weeks = solarMonth.getWeeks(weekStart);
const result: DayInfo[][] = [];
for (let w = 0; w < weekCount; w++) {
const week = weeks[w];
const days = week.getDays();
const row: DayInfo[] = [];
for (const day of days) {
row.push(solarDayToDayInfo(day));
}
result.push(row);
}
return result;
}
/** Get DayInfo for a specific date */
export function getDayInfo(year: number, month: number, day: number): DayInfo {
const solarDay = SolarDay.fromYmd(year, month, day);
return solarDayToDayInfo(solarDay);
}
/** Get today's DayInfo */
export function getTodayInfo(): DayInfo {
const now = new Date();
return getDayInfo(now.getFullYear(), now.getMonth() + 1, now.getDate());
}
@@ -0,0 +1,59 @@
/**
* 流月计算:按节气月(立春起 12 节)划分某公历年的 12 个流月
*/
import { SolarTerm } from 'tyme4ts';
import { getDayInfo } from './day';
export interface YearMonthInfo {
/** 0-11,从寅月(正月/立春)起 */
index: number;
/** 正月..腊月 */
name: string;
/** 起始公历月 */
solarMonth: number;
/** 节日期 YYYY-MM-DD */
startDate: string;
/** 月末 YYYY-MM-DD(下一节前一天) */
endDate: string;
/** 月柱干支 */
ganzhi: string;
}
// SolarTerm 索引:冬至0 小寒1 大寒2 立春3 雨水4 惊蛰5 春分6 清明7 谷雨8 立夏9 小满10 芒种11 夏至12 小暑13 大暑14 立秋15 处暑16 白露17 秋分18 寒露19 霜降20 立冬21 小雪22 大雪23
const JIE_INDICES = [3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 1]; // 立春..小寒(次年)
const MONTH_NAME_BY_BRANCH: Record<string, string> = {
'寅': '正月', '卯': '二月', '辰': '三月', '巳': '四月', '午': '五月', '未': '六月',
'申': '七月', '酉': '八月', '戌': '九月', '亥': '十月', '子': '冬月', '丑': '腊月',
};
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function toDateStr(day: { getYear(): number; getMonth(): number; getDay(): number }): string {
return `${day.getYear()}-${pad(day.getMonth())}-${pad(day.getDay())}`;
}
/** Get the 12 节气月(流月)of a solar year, starting from 立春 */
export function getYearMonths(year: number): YearMonthInfo[] {
const months: YearMonthInfo[] = [];
for (let k = 0; k < 12; k++) {
const termYear = k === 11 ? year + 1 : year;
const start = SolarTerm.fromIndex(termYear, JIE_INDICES[k]).getSolarDay();
const end = k === 11
? start
: SolarTerm.fromIndex(year, JIE_INDICES[k + 1]).getSolarDay().next(-1);
const di = getDayInfo(start.getYear(), start.getMonth(), start.getDay());
const branch = di.lunarMonthGanzhi[1];
months.push({
index: k,
name: MONTH_NAME_BY_BRANCH[branch] || di.lunarMonthName || `${k + 1}月`,
solarMonth: start.getMonth(),
startDate: toDateStr(start),
endDate: toDateStr(end),
ganzhi: di.lunarMonthGanzhi,
});
}
return months;
}
+89
View File
@@ -0,0 +1,89 @@
/** Full almanac (黄历) information for a single day */
export interface AlmanacInfo {
// Duty officer (建除十二值神)
duty: string;
dutyLuck: 'good' | 'bad' | 'neutral';
// Twelve star (黄道黑道十二神)
twelveStar: {
name: string;
ecliptic: string; // 黄道 or 黑道
luck: 'good' | 'bad';
};
// Twenty-eight lunar mansion (二十八星宿)
twentyEightStar: {
name: string;
luck: 'good' | 'bad';
animal: string;
};
// Nine star (九星)
nineStar: {
name: string;
color: string;
element: string;
};
// Six star (六曜)
sixStar: string;
// Minor Ren (小六壬)
minorRen: {
name: string;
luck: 'good' | 'bad';
element: string;
};
// Moon phase
phase: string;
// Fetus god (胎神)
fetus: {
direction: string;
side: string; // 房内/房外
position: string; // Full position description
};
// Recommendations and avoidances (宜忌)
recommends: string[];
avoids: string[];
// Gods (神煞)
goodGods: string[];
badGods: string[];
// Day stem-branch info
dayStem: string; // 天干 e.g. "甲"
dayBranch: string; // 地支 e.g. "子"
dayGanzhi: string; // 干支 e.g. "甲子"
// Peng Zu taboo (彭祖百忌)
pengZu: string;
pengZuStem: string; // 天干禁忌
pengZuBranch: string; // 地支禁忌
// Chong/Sha/Harm/Combine (冲煞害合)
clash: string; // 冲 e.g. "马(午)"
harm: string; // 害 e.g. "羊(未)"
combine: string; // 合 e.g. "牛(丑)"
evilDirection: string;// 煞 e.g. "北"
// Na Yin sound (纳音)
nayin: string;
// Hourly almanac
hourDetails: HourAlmanac[];
}
/** Hourly almanac for each of the 12 two-hour periods (时辰) */
export interface HourAlmanac {
branch: string; // 地支 e.g. "子"
name: string; // 时辰名 e.g. "子时"
range: string; // Time range e.g. "23:00-01:00"
ganzhi: string; // Hour pillar e.g. "甲子"
recommends: string[];
avoids: string[];
twelveStar: string;
nineStar: string;
}
+98
View File
@@ -0,0 +1,98 @@
/** A single pillar (柱) in the Bazi — year, month, day, or hour */
export interface PillarInfo {
ganzhi: string; // "甲子"
heavenStem: string; // "甲"
earthBranch: string; // "子"
elementStem: string; // Stem's five element "木"
elementBranch: string; // Branch's five element "水"
yinYangStem: 'yin' | 'yang';
yinYangBranch: 'yin' | 'yang';
hideStems: HideStemInfo[];
nayin: string; // Na Yin sound "海中金"
terrain: {
name: string; // 十二长生 stage
fortune: 'good' | 'bad' | 'neutral';
};
tenStar: string | null; // Ten Star relative to day master
}
/** Hidden stem within an earth branch (藏干) */
export interface HideStemInfo {
stem: string; // "甲"
type: string; // "本气" | "中气" | "余气"
tenStar: string | null; // Ten Star relative to day master
}
/** Complete Bazi (八字) result for a birth date/time */
export interface EightCharInfo {
birthDate: string; // ISO date
birthTime: string; // HH:mm
gender: 'male' | 'female';
yearPillar: PillarInfo;
monthPillar: PillarInfo;
dayPillar: PillarInfo; // Day master pillar
hourPillar: PillarInfo;
// Derived
dayMaster: string; // "甲木" — day stem + element
dayMasterStem: string; // "甲"
dayMasterElement: string; // "木"
fetalOrigin: string; // 胎元
fetalBreath: string; // 胎息
ownSign: string; // 命宫
bodySign: string; // 身宫
emptyBranches: string[]; // 空亡 branches
emptyTen: string; // 旬
}
/** Decade fortune (大运) */
export interface DecadeFortuneInfo {
index: number;
ganzhi: string;
startAge: number;
endAge: number;
startYear: number;
endYear: number;
heavenStem: string;
earthBranch: string;
nayin: string;
/** 该大运内的 10 个流年 */
annualFortunes: FortuneInfo[];
}
/** Annual fortune (流年/小运) */
export interface FortuneInfo {
age: number;
year: number;
ganzhi: string;
nayin: string;
}
/** Child limit (起运) information */
export interface ChildLimitInfo {
/** Actual birth time used as the start of the child-limit interval. */
birthTime: string;
/** Actual first-luck transition time (交运时间). */
startTime: string;
endTime: string;
yearCount: number;
monthCount: number;
dayCount: number;
hourCount: number;
minuteCount: number;
forward: boolean; // 顺排/逆排
startAge: number;
endAge: number;
}
/** Full Bazi analysis result */
export interface BaziFullResult {
eightChar: EightCharInfo;
childLimit: ChildLimitInfo;
decadeFortunes: DecadeFortuneInfo[];
annualFortunes: FortuneInfo[];
/** True when the caller applied solar-time longitude correction to the birth time */
solarAdjusted?: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
/** Core calendar day information — framework-agnostic plain object */
export interface DayInfo {
/** ISO date string YYYY-MM-DD */
solarDate: string;
solarDay: number;
solarMonth: number;
solarYear: number;
/** Chinese weekday name: 日,一,二,三,四,五,六 */
weekDay: string;
/** 0=Sunday ... 6=Saturday */
weekDayIndex: number;
/** Western zodiac constellation name */
constellation: string;
/** Solar term name if this day is a term day */
solarTerm: string | null;
/** Exact solar term time (HH:mm) */
solarTermTime: string | null;
/** Whether this day is the exact solar term transition day */
isTermDay: boolean;
/** The solar term this day belongs to (e.g. 大暑) */
currentSolarTerm: string | null;
/** Season name: 春季/夏季/秋季/冬季 */
season: string;
/** 1-based day index within the current solar term (节气第几天) */
termDayIndex: number | null;
/** Next solar term name */
nextSolarTerm: string | null;
/** Days until the next solar term */
daysToNextTerm: number | null;
/** Julian Day number (儒略日) */
julianDay: number | null;
/** Buddhist Era year (佛历年 = 公历年 + 543) */
buddhistYear: number | null;
/** Islamic/Hijri date as "1448年02月18日" */
hijriDate: string | null;
/** Buddhist festival name (农历) if applicable */
buddhistFestival: string | null;
/** 72 phenology name if applicable */
phenology: string | null;
/** Three periods (三伏) if applicable */
dogDay: string | null;
/** Nine-day cold period (数九) if applicable */
nineDay: string | null;
// Lunar calendar
lunarYear: number;
lunarMonth: number;
/** Chinese lunar month name e.g. "五月" */
lunarMonthName: string;
lunarDay: number;
/** Chinese lunar day name e.g. "初十", "廿一" */
lunarDayName: string;
isLeapMonth: boolean;
/** Gan-Zhi of the lunar year e.g. "丙午" */
lunarYearGanzhi: string;
/** Gan-Zhi of the lunar month */
lunarMonthGanzhi: string;
/** Gan-Zhi of the lunar day */
lunarDayGanzhi: string;
/** Chinese zodiac animal */
zodiac: string;
// Festivals & holidays
lunarFestival: string | null;
solarFestival: string | null;
legalHoliday: { name: string; isWork: boolean } | null;
// Moon phase
moonPhase: string | null;
// Metadata
isToday: boolean;
isWeekend: boolean;
/** Day-of-week position within the month grid (0-based column) */
dayOfWeek: number;
}
+66
View File
@@ -0,0 +1,66 @@
/** Relationship between a single pillar in user's Bazi and day's Bazi */
export interface PillarRelationship {
pillar: 'year' | 'month' | 'day' | 'hour';
pillarLabel: string; // "年柱", "月柱", "日柱", "时柱"
userGanzhi: string;
dayGanzhi: string;
// Heaven stem relationships
stemTenStar: string; // Ten Star: day stem vs user stem
stemCombine: boolean; // 天干合
stemOpposite: boolean; // 天干冲
// Earth branch relationships
branchCombine: boolean; // 六合
branchThreeCombine: boolean; // 三合
branchOpposite: boolean; // 六冲
branchHarm: boolean; // 六害
branchPunish: boolean; // 相刑
branchFormation: string | null;// 三合局名
// Score contribution
score: number; // -10 to +10
}
/** Complete daily fortune result for a user on a specific day */
export interface DailyFortuneResult {
date: string; // ISO date
lunarDate: string; // Lunar date description
dayGanzhi: string; // Day's Gan-Zhi
/** Overall score from -100 to +100 */
overallScore: number;
/** Score level classification */
scoreLevel: 'great' | 'good' | 'fair' | 'poor' | 'bad';
/** Pillar-by-pillar relationship analysis */
pillarRelationships: PillarRelationship[];
/** Positive aspects of the day */
luckyAspects: string[];
/** Negative aspects / warnings */
unluckyAspects: string[];
/** Actionable suggestions */
suggestions: string[];
/** Primary affected life area */
affectedAreas: string[];
/** Lucky meta info for the day (personalized) */
luckyMeta: {
colors: string[];
numbers: number[];
direction: string;
element: string;
activity: string;
};
/** Category scores (-100 to +100) */
categoryScores: {
love: number;
career: number;
wealth: number;
health: number;
};
}
+15
View File
@@ -0,0 +1,15 @@
export type { DayInfo } from './calendar';
export type { AlmanacInfo, HourAlmanac } from './almanac';
export type {
PillarInfo,
HideStemInfo,
EightCharInfo,
DecadeFortuneInfo,
FortuneInfo,
ChildLimitInfo,
BaziFullResult,
} from './bazi';
export type {
PillarRelationship,
DailyFortuneResult,
} from './fortune';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022"]
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
splitting: false,
treeshake: true,
});
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#FFFBF5" />
<meta name="description" content="万年历 - 农历黄历八字运势" />
<title>万年历</title>
<script>
// Prevent dark mode flicker
(function() {
const theme = localStorage.getItem('lunar-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@lunar/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"clean": "rm -rf dist"
},
"dependencies": {
"@lunar/core": "workspace:*",
"framer-motion": "^12.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"zustand": "^5.0.0",
"lucide-react": "^0.468.0",
"clsx": "^2.1.0",
"tailwind-variants": "^0.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.4.0",
"vite-plugin-pwa": "^0.21.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#C41E3A"/>
<text x="32" y="44" text-anchor="middle" font-size="36" font-family="serif" fill="white">历</text>
</svg>

After

Width:  |  Height:  |  Size: 226 B

+68
View File
@@ -0,0 +1,68 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AppShell } from './components/layout/AppShell';
import { ErrorBoundary } from './components/ui/ErrorBoundary';
import { PageSkeleton } from './components/ui/Skeleton';
// Lazy load pages
const HomePage = lazy(() => import('./pages/HomePage'));
const CalendarPage = lazy(() => import('./pages/CalendarPage'));
const DayDetailPage = lazy(() => import('./pages/DayDetailPage'));
const BaziPage = lazy(() => import('./pages/BaziPage'));
const DailyFortunePage = lazy(() => import('./pages/DailyFortunePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const DivinationPage = lazy(() => import('./pages/DivinationPage'));
const SolarTermsPage = lazy(() => import('./pages/SolarTermsPage'));
const pageVariants = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12 },
};
function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}
function SafePage({ children }: { children: React.ReactNode }) {
return (
<PageTransition>
<ErrorBoundary>{children}</ErrorBoundary>
</PageTransition>
);
}
export default function App() {
const location = useLocation();
return (
<AppShell>
<AnimatePresence mode="wait">
<Suspense fallback={<PageSkeleton />}>
<Routes location={location} key={location.pathname}>
<Route path="/" element={<SafePage><HomePage /></SafePage>} />
<Route path="/calendar" element={<SafePage><CalendarPage /></SafePage>} />
<Route path="/calendar/:date" element={<SafePage><DayDetailPage /></SafePage>} />
<Route path="/bazi" element={<SafePage><BaziPage /></SafePage>} />
<Route path="/daily-fortune" element={<SafePage><DailyFortunePage /></SafePage>} />
<Route path="/settings" element={<SafePage><SettingsPage /></SafePage>} />
<Route path="/divination" element={<SafePage><DivinationPage /></SafePage>} />
<Route path="/solar-terms" element={<SafePage><SolarTermsPage /></SafePage>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AnimatePresence>
</AppShell>
);
}
@@ -0,0 +1,122 @@
import { cn } from '../../lib/utils';
import { motion } from 'framer-motion';
import { Star } from 'lucide-react';
import type { DayInfo } from '@lunar/core';
interface CalendarCellProps {
dayInfo: DayInfo;
isSelected: boolean;
isCurrentMonth: boolean;
fortuneScore?: number;
hasBookmark?: boolean;
expanded?: boolean;
onClick: () => void;
}
// Short festival abbreviations
function shortFestival(di: DayInfo): string | null {
if (di.lunarFestival) {
if (di.lunarFestival.includes('春节')) return '春节';
if (di.lunarFestival.includes('元宵')) return '元宵';
if (di.lunarFestival.includes('清明')) return '清明';
if (di.lunarFestival.includes('端午')) return '端午';
if (di.lunarFestival.includes('七夕')) return '七夕';
if (di.lunarFestival.includes('中秋')) return '中秋';
if (di.lunarFestival.includes('重阳')) return '重阳';
if (di.lunarFestival.includes('除夕')) return '除夕';
if (di.lunarFestival.includes('腊八')) return '腊八';
if (di.lunarFestival.includes('冬至')) return '冬至';
if (di.lunarFestival.includes('小年') || di.lunarFestival.includes('灶')) return '小年';
return '节';
}
if (di.solarFestival) {
if (di.solarFestival.includes('元旦')) return '元旦';
if (di.solarFestival.includes('国庆')) return '国庆';
if (di.solarFestival.includes('劳动')) return '劳动';
if (di.solarFestival.includes('妇女')) return '妇女';
if (di.solarFestival.includes('儿童')) return '儿童';
if (di.solarFestival.includes('情人')) return '情人';
return '节';
}
if (di.legalHoliday && !di.legalHoliday.isWork) return '休';
return null;
}
export function CalendarCell({ dayInfo, isSelected, isCurrentMonth, fortuneScore, hasBookmark, expanded, onClick }: CalendarCellProps) {
const fest = shortFestival(dayInfo);
const hasTerm = !!dayInfo.solarTerm;
return (
<motion.button
whileTap={{ scale: 0.92 }}
onClick={onClick}
className={cn(
'relative flex flex-col items-center justify-center aspect-square p-0.5',
'border-b border-r border-border/50',
!isCurrentMonth && 'opacity-30',
isSelected && !dayInfo.isToday && 'bg-primary/10 rounded-lg',
dayInfo.isToday && 'bg-primary rounded-md',
)}
>
{/* Festival / Solar term label at top */}
{(fest || hasTerm) && (
<span className={cn(
'text-[8px] leading-none font-medium relative z-10 truncate max-w-full px-0.5',
fest ? 'text-red-500' : 'text-green-600',
)}>
{fest || (hasTerm ? dayInfo.solarTerm!.slice(0, 2) : '')}
</span>
)}
{/* Fortune dot (if user has bazi) */}
{fortuneScore !== undefined && (
<div className={`absolute top-0.5 left-0.5 w-1.5 h-1.5 rounded-full z-10 ${
fortuneScore >= 20 ? 'bg-red-500' : fortuneScore <= -20 ? 'bg-slate-500' : 'bg-amber-400'
}`} />
)}
{/* Bookmark star */}
{hasBookmark && (
<Star size={8} className="absolute top-0.5 right-0.5 text-amber-500 fill-amber-500 z-10" />
)}
{/* Solar day number */}
<span className={cn(
'text-sm font-semibold leading-tight relative z-10',
dayInfo.isToday && 'text-white',
dayInfo.isWeekend && !dayInfo.isToday && 'text-red-500',
!isCurrentMonth && 'text-muted',
)}>
{dayInfo.solarDay}
</span>
{/* Lunar day name or festival */}
<span className={cn(
'text-[10px] leading-tight relative z-10 truncate max-w-full px-0.5',
dayInfo.isToday && 'text-white/80',
!dayInfo.isToday && 'text-secondary',
dayInfo.lunarDay === 1 && !dayInfo.isToday && 'text-primary font-medium',
dayInfo.lunarDay === 15 && !dayInfo.isToday && 'text-primary font-medium',
)}>
{dayInfo.lunarDayName}
</span>
{/* Extra info in week view */}
{expanded && (
<div className="relative z-10 mt-0.5 space-y-0.5">
{dayInfo.lunarFestival && (
<span className="text-[8px] text-red-500 block truncate">{dayInfo.lunarFestival}</span>
)}
{dayInfo.solarTerm && (
<span className="text-[8px] text-green-600 block truncate">{dayInfo.solarTerm}</span>
)}
{fortuneScore !== undefined && (
<span className={`text-[8px] font-medium ${fortuneScore>=20?'text-red-500':fortuneScore<=-20?'text-slate-500':'text-amber-500'}`}>
{fortuneScore>=20?'吉':fortuneScore<=-20?'凶':'平'} {fortuneScore}
</span>
)}
</div>
)}
</motion.button>
);
}
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import type { DayInfo } from '@lunar/core';
import { CalendarCell } from './CalendarCell';
import { WeekDayBar } from './WeekDayBar';
import { MonthYearPicker } from './MonthYearPicker';
interface CalendarGridProps {
days: DayInfo[][];
viewDate: Date;
selectedDate: Date | null;
weekStart?: 0 | 1;
fortuneScores?: Record<string, number> | null;
bookmarkedDates?: Set<string>;
onPrevMonth: () => void;
onNextMonth: () => void;
onPrevYear: () => void;
onNextYear: () => void;
onSelectDate: (d: Date) => void;
}
export function CalendarGrid({
days, viewDate, selectedDate, weekStart = 0, fortuneScores, bookmarkedDates,
onPrevMonth, onNextMonth, onPrevYear, onNextYear, onSelectDate,
}: CalendarGridProps) {
const navigate = useNavigate();
const [viewMode, setViewMode] = useState<'month'|'week'>('month');
const monthKey = `${viewDate.getFullYear()}-${viewDate.getMonth()}`;
const handleDayClick = (dayInfo: DayInfo) => {
const [y, m, d] = dayInfo.solarDate.split('-').map(Number);
onSelectDate(new Date(y, m - 1, d));
navigate(`/calendar/${dayInfo.solarDate}`);
};
const isSelected = (di: DayInfo): boolean => {
if (!selectedDate) return false;
const [y, m, d] = di.solarDate.split('-').map(Number);
return selectedDate.getFullYear()===y && selectedDate.getMonth()===m-1 && selectedDate.getDate()===d;
};
// Week view: show the week containing the selected date, or current week
const selectedWeek = selectedDate
? days.findIndex(w => w.some(d => {
const [y,m,day] = d.solarDate.split('-').map(Number);
return selectedDate.getFullYear()===y && selectedDate.getMonth()===m-1 && selectedDate.getDate()===day;
}))
: -1;
const displayWeek = selectedWeek >= 0 ? selectedWeek : 0;
const displayDays = viewMode === 'week' ? (days[displayWeek] || days[0] || []) : days.flat();
return (
<div className="space-y-2">
<MonthYearPicker viewDate={viewDate}
onPrevMonth={onPrevMonth} onNextMonth={onNextMonth}
onPrevYear={onPrevYear} onNextYear={onNextYear} />
{/* View mode toggle */}
<div className="flex rounded-lg bg-card border border-border p-0.5">
<button onClick={() => setViewMode('month')}
className={`flex-1 py-1 text-xs rounded-md font-medium transition-colors ${viewMode==='month'?'bg-primary text-white':'text-secondary'}`}>月</button>
<button onClick={() => setViewMode('week')}
className={`flex-1 py-1 text-xs rounded-md font-medium transition-colors ${viewMode==='week'?'bg-primary text-white':'text-secondary'}`}>周</button>
</div>
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<WeekDayBar weekStart={weekStart} />
<motion.div key={`${monthKey}-${viewMode}`} initial={{opacity:0}} animate={{opacity:1}} transition={{duration:0.12}}
className={`grid grid-cols-7 ${viewMode==='week'?'auto-rows-fr':''}`}>
{displayDays.map((dayInfo, idx) => (
<CalendarCell key={`${dayInfo.solarDate}-${idx}`} dayInfo={dayInfo}
isSelected={isSelected(dayInfo)}
isCurrentMonth={dayInfo.solarMonth===viewDate.getMonth()+1 && dayInfo.solarYear===viewDate.getFullYear()}
fortuneScore={fortuneScores?.[dayInfo.solarDate]}
hasBookmark={bookmarkedDates?.has(dayInfo.solarDate)}
expanded={viewMode === 'week'}
onClick={() => handleDayClick(dayInfo)} />
))}
</motion.div>
</div>
{/* Month summary (fortune) */}
{fortuneScores && (
<MonthSummary scores={fortuneScores} days={days} />
)}
</div>
);
}
function MonthSummary({ scores, days }: { scores: Record<string, number>; days: DayInfo[][] }) {
const allDays = days.flat().filter(d => scores[d.solarDate] !== undefined);
if (allDays.length === 0) return null;
const good = allDays.filter(d => scores[d.solarDate] >= 20).length;
const bad = allDays.filter(d => scores[d.solarDate] <= -20).length;
const fair = allDays.length - good - bad;
const best = allDays.reduce((a, b) => (scores[a.solarDate] > scores[b.solarDate]) ? a : b);
const worst = allDays.reduce((a, b) => (scores[a.solarDate] < scores[b.solarDate]) ? a : b);
return (
<div className="bg-card rounded-xl border border-border p-2.5">
<p className="text-xs font-medium mb-1.5">本月运势概览</p>
<div className="flex items-center gap-3 text-xs mb-1.5">
<span className="text-red-500">吉 {good}天</span>
<span className="text-amber-500">平 {fair}天</span>
<span className="text-slate-500">凶 {bad}天</span>
</div>
<div className="flex justify-between text-[10px] text-muted">
<span>最佳: {best.lunarMonthName}{best.lunarDayName} ({scores[best.solarDate]})</span>
<span>谨慎: {worst.lunarMonthName}{worst.lunarDayName} ({scores[worst.solarDate]})</span>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
interface MonthYearPickerProps {
viewDate: Date;
onPrevMonth: () => void;
onNextMonth: () => void;
onPrevYear: () => void;
onNextYear: () => void;
}
export function MonthYearPicker({
viewDate,
onPrevMonth,
onNextMonth,
onPrevYear,
onNextYear,
}: MonthYearPickerProps) {
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
return (
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-0.5">
<button onClick={onPrevYear} className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted shrink-0" title="上一年">
<ChevronsLeft size={15} />
</button>
<button onClick={onPrevMonth} className="p-1 rounded-lg hover:bg-foreground/5 text-secondary shrink-0" title="上一月">
<ChevronLeft size={17} />
</button>
</div>
<h2 className="text-sm sm:text-base font-bold font-chinese text-foreground select-none text-center whitespace-nowrap mx-1">
{viewDate.getFullYear()}年{monthNames[viewDate.getMonth()]}
</h2>
<div className="flex items-center gap-0.5">
<button onClick={onNextMonth} className="p-1 rounded-lg hover:bg-foreground/5 text-secondary shrink-0" title="下一月">
<ChevronRight size={17} />
</button>
<button onClick={onNextYear} className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted shrink-0" title="下一年">
<ChevronsRight size={15} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
const DAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
export function WeekDayBar({ weekStart = 0 }: { weekStart?: 0 | 1 }) {
const labels = weekStart === 1 ? [...DAY_LABELS.slice(1), DAY_LABELS[0]] : DAY_LABELS;
return (
<div className="grid grid-cols-7 border-b border-border">
{labels.map((label, idx) => {
const weekIdx = (idx + weekStart) % 7;
const isWeekend = weekIdx === 0 || weekIdx === 6;
return (
<div
key={idx}
className={`
flex items-center justify-center py-2 text-xs font-medium
${isWeekend ? 'text-red-400' : 'text-muted'}
`}
>
{label}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import { Header } from './Header';
import { BottomNav } from './BottomNav';
interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="pt-12 pb-16 md:pb-4">
<div className="container-page">
{children}
</div>
</main>
<BottomNav />
</div>
);
}
@@ -0,0 +1,43 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { Home, CalendarDays, Stars, TrendingUp, Settings } from 'lucide-react';
import { cn } from '../../lib/utils';
const NAV_ITEMS = [
{ path: '/', label: '首页', icon: Home, exact: true },
{ path: '/calendar', label: '日历', icon: CalendarDays },
{ path: '/bazi', label: '八字', icon: Stars },
{ path: '/daily-fortune', label: '运势', icon: TrendingUp },
{ path: '/settings', label: '设置', icon: Settings },
];
export function BottomNav() {
const navigate = useNavigate();
const location = useLocation();
return (
<nav className="fixed bottom-0 left-0 right-0 z-30 md:hidden bg-background/80 backdrop-blur-lg border-t border-border safe-area-bottom">
<div className="flex items-center justify-around h-14">
{NAV_ITEMS.map((item) => {
const isActive = item.exact
? location.pathname === '/'
: location.pathname === item.path || location.pathname.startsWith(item.path + '/');
const Icon = item.icon;
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className={cn(
'flex flex-col items-center justify-center gap-0.5 min-w-0 px-2 py-1 rounded-lg transition-colors',
isActive ? 'text-primary' : 'text-muted hover:text-secondary',
)}
>
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
<span className="text-[10px] font-medium">{item.label}</span>
</button>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,43 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { Sun, Moon, Calendar, Home } from 'lucide-react';
import { useSettingsStore } from '../../stores/settings';
interface HeaderProps {
title?: string;
}
export function Header({ title = '万年历' }: HeaderProps) {
const navigate = useNavigate();
const location = useLocation();
const { theme, setTheme } = useSettingsStore();
const toggleTheme = () => setTheme(theme === 'dark' ? 'light' : 'dark');
const isHome = location.pathname === '/';
return (
<header className="fixed top-0 left-0 right-0 z-30 h-12 bg-background/80 backdrop-blur-lg border-b border-border">
<div className="container-page h-full flex items-center justify-between">
<button
onClick={() => navigate('/')}
className="p-1.5 -ml-1.5 rounded-lg hover:bg-foreground/5 flex items-center gap-1"
>
<span className="text-base font-bold font-chinese text-primary">{title}</span>
</button>
<div className="flex items-center gap-0.5">
{!isHome && (
<button onClick={() => navigate('/')} className="p-1.5 rounded-lg hover:bg-foreground/5 text-secondary" title="首页">
<Home size={16} />
</button>
)}
<button onClick={() => navigate('/calendar')} className="p-1.5 rounded-lg hover:bg-foreground/5 text-secondary" title="日历">
<Calendar size={16} />
</button>
<button onClick={toggleTheme} className="p-1.5 rounded-lg hover:bg-foreground/5 text-secondary" title="切换主题">
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,54 @@
import { motion, AnimatePresence } from 'framer-motion';
import type { ReactNode } from 'react';
interface AnimatedPanelProps {
children: ReactNode;
isOpen: boolean;
onClose: () => void;
position?: 'bottom' | 'right';
}
export function AnimatedPanel({
children,
isOpen,
onClose,
position = 'bottom',
}: AnimatedPanelProps) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/30 z-40"
/>
{/* Panel */}
<motion.div
initial={position === 'bottom' ? { y: '100%' } : { x: '100%' }}
animate={position === 'bottom' ? { y: 0 } : { x: 0 }}
exit={position === 'bottom' ? { y: '100%' } : { x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className={
position === 'bottom'
? 'fixed bottom-0 left-0 right-0 z-50 max-h-[85vh] overflow-y-auto rounded-t-2xl bg-card shadow-xl'
: 'fixed right-0 top-0 h-full z-50 w-80 overflow-y-auto bg-card shadow-xl'
}
>
{/* Drag handle for bottom sheet */}
{position === 'bottom' && (
<div className="flex justify-center pt-3 pb-1 sticky top-0 bg-card z-10">
<div className="w-10 h-1 rounded-full bg-border" />
</div>
)}
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
}

Some files were not shown because too many files have changed in this diff Show More