Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241a04496b | ||
|
|
bff792a974 | ||
|
|
5a5388234a | ||
|
|
fec383f257 | ||
|
|
0ce1a1ef97 | ||
|
|
cc7f30191e | ||
|
|
e0b447ddb1 | ||
|
|
d175777d75 | ||
|
|
0256f4e7f3 | ||
|
|
9ccab93c5b | ||
|
|
2a68eae999 | ||
|
|
52c0d3c774 | ||
|
|
738b5c4452 | ||
|
|
a45004bfb2 | ||
|
|
f468cf4b94 | ||
|
|
d6ce5cd0f3 | ||
|
|
3cc3edeef9 | ||
|
|
8b5bfe8385 | ||
|
|
a29328f35b | ||
|
|
87f5fa2478 | ||
|
|
f7d88622e0 | ||
|
|
ac2c99c3bc | ||
|
|
4efdd20ff7 | ||
|
|
297747041a | ||
|
|
50975e0627 | ||
|
|
013e7ab99a | ||
|
|
4b003fad39 | ||
|
|
b971a34f7f | ||
|
|
fe5bed0668 | ||
|
|
f5c8d06fbe | ||
|
|
fb1bd57af0 | ||
|
|
a6bdfee548 | ||
|
|
23442017b3 | ||
|
|
320231808b | ||
|
|
fdc11bf529 | ||
|
|
d73bbf53cf | ||
|
|
ffac513b4a | ||
|
|
fdbf8307bb | ||
|
|
e92b739cb6 | ||
|
|
b507d6c154 | ||
|
|
fc7a30ad9d | ||
|
|
9e93198ef2 | ||
|
|
0deb5ba7b6 |
@@ -0,0 +1,22 @@
|
|||||||
|
# lunar-server 容器编排(doc79 同机部署,通过 1Panel Compose 编排创建)
|
||||||
|
# 用法:1Panel → 容器 → Compose → 创建编排,粘贴本文件内容后部署。
|
||||||
|
# 前置:先在 /opt/lunar/production/.env 写入全部生产环境变量(模板见 PULL_DEPLOY.md)。
|
||||||
|
# 之后 CI 产物由 1Panel 计划任务触发的 deploy-watch.sh 自动替换
|
||||||
|
# /opt/lunar/production/current 并重启容器(container_name 必须保持 lunar-server,
|
||||||
|
# deploy.sh 按此名称重启)。
|
||||||
|
|
||||||
|
services:
|
||||||
|
lunar-server:
|
||||||
|
image: golang:1.22-bookworm
|
||||||
|
container_name: lunar-server
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- /opt/lunar/production/current:/app
|
||||||
|
- /opt/lunar/production/data:/app/data
|
||||||
|
- /opt/lunar/production/.env:/app/.env
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8081:8080" # 只监听本机,对外由 nginx 反代 lunar.neatcn.com
|
||||||
|
env_file:
|
||||||
|
- /opt/lunar/production/.env
|
||||||
|
restart: unless-stopped
|
||||||
|
command: /app/bin/server
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# 1Panel 自动部署配置指南
|
# 1Panel 自动部署配置指南
|
||||||
|
|
||||||
|
> ⚠️ **本文档已废弃**(2026-08-09):文中“SSH 到服务器手工执行”的方式违反安全红线(服务器曾因 AI 工具持有 SSH 密钥被入侵)。
|
||||||
|
> 现行方案请看 [PULL_DEPLOY.md](./PULL_DEPLOY.md):CI 只构建,部署由服务器本地 watcher 自动拉取,任何 AI/CI 不再 SSH。
|
||||||
|
> 本文仅作历史参考,禁止按其中步骤操作。
|
||||||
|
|
||||||
本文档说明如何配置 1Panel 实现后端服务的全自动部署。
|
本文档说明如何配置 1Panel 实现后端服务的全自动部署。
|
||||||
|
|
||||||
## 架构说明
|
## 架构说明
|
||||||
|
|||||||
+104
-47
@@ -1,68 +1,125 @@
|
|||||||
# 拉取式部署配置(一次性)
|
# doc79 同机拉取式部署配置(1Panel 原生方式,一次性)
|
||||||
|
|
||||||
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
|
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
|
||||||
> 部署由服务器本地的 watcher 监听 CI 产物目录自动完成。本文档的所有命令由**管理员本人**在服务器上执行一次。
|
> 部署由服务器本地定时任务监听 CI 产物目录自动完成。
|
||||||
|
> 本文档所有操作均通过 **1Panel 网页界面**或管理员本人的终端完成,不依赖 AI 远程执行。
|
||||||
|
|
||||||
## 工作原理
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
push main → Gitea Actions 构建 → 产物写入 /ci-artifacts(runner 与宿主同机)
|
push main → Gitea Actions(hk runner 构建)→ 部署包发布为 Gitea Release 附件
|
||||||
↓
|
↓
|
||||||
服务器本地 systemd timer 每分钟运行 deploy-watch.sh → 发现新 sha256 → 执行 deploy.sh
|
1Panel 计划任务(每分钟)运行 deploy-watch.sh → 发现新 release → HTTPS 拉取 deploy.tar.gz
|
||||||
↓
|
↓
|
||||||
目录级原子切换 → docker restart → HTTP 健康检查 → 失败自动回滚
|
deploy.sh 原子切换 + 重启容器 + 健康检查(失败自动回滚)
|
||||||
|
↓
|
||||||
|
EdgeOne CDN (HTTPS 终结) → doc79:80 → 1Panel OpenResty 反代 → 127.0.0.1:8080 lunar-server 容器
|
||||||
```
|
```
|
||||||
|
|
||||||
## 一次性安装(管理员手工执行)
|
> 产物通过 Gitea Release 分发,与 runner 位置解耦(hk/bt 均可构建);
|
||||||
|
> 服务器侧只出站 HTTPS 拉取,无任何入站 SSH。
|
||||||
|
|
||||||
|
## 一次性安装步骤(按序执行)
|
||||||
|
|
||||||
|
### 1. EdgeOne 回源配置(控制台)
|
||||||
|
|
||||||
|
- 回源协议改为 **HTTP**(HTTPS 由 EdgeOne 终结,源站无需证书,消除 525);
|
||||||
|
- 回源地址:doc79 公网 IP,端口 80;回源 Host:`lunar.neatcn.com`。
|
||||||
|
|
||||||
|
### 2. 1Panel 创建反向代理网站
|
||||||
|
|
||||||
|
1Panel → **网站** → 创建网站 → **反向代理**:
|
||||||
|
|
||||||
|
- 主域名:`lunar.neatcn.com`
|
||||||
|
- 代理地址:`http://127.0.0.1:8080`
|
||||||
|
|
||||||
|
创建后如需补充请求头,可在站点「配置文件」中加入:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
```
|
||||||
|
|
||||||
|
(1Panel 的 OpenResty 默认已带 Host 头转发,以上为可选项。)
|
||||||
|
|
||||||
|
### 3. 生产环境变量(切勿写入仓库)
|
||||||
|
|
||||||
|
1Panel → **文件** → 进入 `/opt/lunar/production/`(不存在先创建),新建 `.env`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
SERVER_ENV=production
|
||||||
|
SERVER_PORT=8080
|
||||||
|
DB_HOST=<数据库地址>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<用户名>
|
||||||
|
DB_PASSWORD=<密码>
|
||||||
|
DB_NAME=lunar
|
||||||
|
JWT_SECRET=<强随机值,至少32位>
|
||||||
|
MINI_APP_ID=<小程序AppID>
|
||||||
|
MINI_APP_SECRET=<小程序AppSecret>
|
||||||
|
WECHAT_PAY_APIKEY=<支付API密钥>
|
||||||
|
WECHAT_PAY_MCHID=<商户号>
|
||||||
|
ADMIN_PASSWORD=<后台登录密码>
|
||||||
|
# 注意:不要配置 CORS_ORIGINS 白名单。小程序请求 Origin 为 servicewechat.com,
|
||||||
|
# 配置白名单会导致跨域头缺失;保持默认(*)即可,鉴权由 JWT/Cookie 保证。
|
||||||
|
```
|
||||||
|
|
||||||
|
文件权限设为 `600`(1Panel 文件管理器右键可改)。
|
||||||
|
|
||||||
|
### 4. 1Panel Compose 编排创建容器
|
||||||
|
|
||||||
|
1Panel → **容器** → **Compose** → 创建编排,名称 `lunar`,粘贴仓库
|
||||||
|
[.gitea/deploy/docker-compose.yml](../deploy/docker-compose.yml) 的完整内容后部署。
|
||||||
|
|
||||||
|
> 首次部署时 `/opt/lunar/production/current` 尚不存在,容器会启动失败——正常现象。
|
||||||
|
> 待第 6 步首次部署落盘后,在 1Panel 里重启该容器即可恢复。
|
||||||
|
|
||||||
|
### 5. 上传部署脚本
|
||||||
|
|
||||||
|
1Panel → **文件** → 进入 `/opt/lunar/scripts/`(不存在先创建),从仓库
|
||||||
|
`.gitea/scripts/` 上传两个文件并赋予可执行权限(右键 → 权限 755):
|
||||||
|
|
||||||
|
- `deploy.sh`(原子切换 + 健康检查 + 失败回滚)
|
||||||
|
- `deploy-watch.sh`(产物指纹轮询)
|
||||||
|
|
||||||
|
### 6. 1Panel 计划任务触发部署
|
||||||
|
|
||||||
|
1Panel → **计划任务** → 创建任务:
|
||||||
|
|
||||||
|
- 任务类型:**Shell 脚本**
|
||||||
|
- 执行周期:固定间隔 1 分钟(或 cron `*/1 * * * *`)
|
||||||
|
- 脚本内容:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 放置脚本
|
# 可选:若仓库私有或需要令牌拉取,在 .env 中配置 GITEA_DEPLOY_TOKEN
|
||||||
sudo mkdir -p /opt/lunar/scripts
|
[ -f /opt/lunar/production/.env ] && set -a && . /opt/lunar/production/.env && set +a
|
||||||
sudo cp deploy.sh deploy-watch.sh /opt/lunar/scripts/ # 从仓库 .gitea/scripts/ 复制
|
bash /opt/lunar/scripts/deploy-watch.sh
|
||||||
sudo chmod +x /opt/lunar/scripts/*.sh
|
|
||||||
|
|
||||||
# 2. systemd timer(每分钟轮询)
|
|
||||||
sudo tee /etc/systemd/system/lunar-deploy-watch.service > /dev/null <<'EOF'
|
|
||||||
[Unit]
|
|
||||||
Description=Lunar pull-based deploy watcher
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/bin/bash /opt/lunar/scripts/deploy-watch.sh
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo tee /etc/systemd/system/lunar-deploy-watch.timer > /dev/null <<'EOF'
|
|
||||||
[Unit]
|
|
||||||
Description=Run lunar deploy watcher every minute
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=1min
|
|
||||||
OnUnitActiveSec=1min
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now lunar-deploy-watch.timer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
保存后可先点「执行」手动跑一次验证(日志在任务详情里查看)。
|
||||||
|
|
||||||
|
### 7. 微信公众平台
|
||||||
|
|
||||||
|
开发管理 → 服务器域名:将 `https://lunar.neatcn.com` 加入 **request 合法域名**。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl list-timers | grep lunar
|
curl -s https://lunar.neatcn.com/api/version # 应返回含 commitSha 的版本 JSON
|
||||||
tail -f /opt/lunar/deploy-watch.log
|
|
||||||
curl -s http://localhost:8080/api/version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 生产容器必备环境变量
|
- CI 侧:push 后 Gitea Actions 日志显示"后端构建成功",Telegram/Discord 收到通知;
|
||||||
|
- 服务器侧:1Panel 计划任务日志出现"部署成功";容器日志可见 `lunar-server 1.0.x` 启动行。
|
||||||
在 1Panel 容器配置中设置(切勿写入仓库):
|
|
||||||
|
|
||||||
- `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`
|
|
||||||
- `JWT_SECRET`(必须为强随机值,禁止使用默认值)
|
|
||||||
- `SERVER_ENV=production`
|
|
||||||
- `MINI_APP_ID` / `MINI_APP_SECRET`(微信登录)
|
|
||||||
- `WECHAT_PAY_APIKEY`(支付回调验签,未配置时回调接口直接返回 503)
|
|
||||||
- `ADMIN_PASSWORD`(管理后台登录,未配置时后台登录禁用)
|
|
||||||
|
|
||||||
## 回滚
|
## 回滚
|
||||||
|
|
||||||
deploy.sh 健康检查失败会自动回滚;手工回滚使用 `/opt/lunar/backups/` 下的备份包重新执行 deploy.sh。
|
- 自动:deploy.sh 健康检查失败会自动恢复 `current.old` 并重启容器;
|
||||||
|
- 手工:用 `/opt/lunar/backups/` 下的备份包重新执行 `bash /opt/lunar/scripts/deploy.sh <备份包>`;
|
||||||
|
- 紧急:在 1Panel 容器页直接重启/回退。
|
||||||
|
|
||||||
|
## 为什么不用 systemd timer / 裸 nginx / 裸 docker-compose?
|
||||||
|
|
||||||
|
doc79 由 1Panel 统一管理:Web 服务是其内置 OpenResty(非系统 nginx)、容器与定时任务
|
||||||
|
有对应的可视化模块。全部落在 1Panel 能力内,日志、重启、证书都在一个面板里可见可控,
|
||||||
|
避免绕过平台管理造成"幽灵配置"。
|
||||||
|
|||||||
@@ -1,46 +1,98 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 拉取式部署 watcher - 监听 CI 产物目录,发现新版本自动部署
|
# 拉取式部署 watcher - 从 Gitea Release 拉取最新部署包并自动部署
|
||||||
# 设计原则:
|
# 设计原则:
|
||||||
# - CI 只负责构建并把产物写入 /ci-artifacts(runner 与本机同宿主)
|
# - CI 在任意 runner(hk 等)构建,产物以 Release 附件发布到 Gitea
|
||||||
# - 部署动作只由服务器本地的本脚本触发,任何 AI/CI 均不通过 SSH 操作服务器
|
# - 本脚本由服务器本地定时任务(1Panel 计划任务)每分钟调用,通过 HTTPS 拉取,
|
||||||
# 安装方式见 .gitea/docs/PULL_DEPLOY.md(由管理员在服务器上手工执行一次)
|
# 任何 AI/CI 均不通过 SSH 操作服务器
|
||||||
|
# 安装方式见 .gitea/docs/PULL_DEPLOY.md(1Panel 原生方式,一次性配置)
|
||||||
|
#
|
||||||
|
# 可选环境变量(建议写入 /opt/lunar/production/.env 并在计划任务里 source):
|
||||||
|
# GITEA_DEPLOY_TOKEN 只读令牌(仓库公开时可省略,匿名即可下载)
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
ARTIFACT="${ARTIFACT:-/ci-artifacts/server-deploy-latest.tar.gz}"
|
GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}"
|
||||||
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-sha256}"
|
REPO="${REPO:-gouki/lunar-mini}"
|
||||||
|
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-release}"
|
||||||
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
|
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
|
||||||
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
|
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
|
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 产物不存在时静默等待
|
AUTH_ARGS=()
|
||||||
if [ ! -f "$ARTIFACT" ]; then
|
if [ -n "${GITEA_DEPLOY_TOKEN:-}" ]; then
|
||||||
|
AUTH_ARGS=(-H "Authorization: token ${GITEA_DEPLOY_TOKEN}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 查询最新 20 个 release,取 tag 前缀为 server-v 且带 deploy.tar.gz 附件的最新一个
|
||||||
|
RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
|
||||||
|
"${AUTH_ARGS[@]}" \
|
||||||
|
"$GITEA_URL/api/v1/repos/$REPO/releases?limit=20" \
|
||||||
|
-o "$WORK_DIR/releases.json" && cat "$WORK_DIR/releases.json") || {
|
||||||
|
log "查询 release 列表失败,等待下次轮询"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
PARSED=$(python3 - "$WORK_DIR/releases.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
releases = json.load(open(sys.argv[1]))
|
||||||
|
for r in releases:
|
||||||
|
if not r.get("tag_name", "").startswith("server-v"):
|
||||||
|
continue
|
||||||
|
if r.get("draft") or r.get("prerelease"):
|
||||||
|
continue
|
||||||
|
for a in r.get("assets", []):
|
||||||
|
if a.get("name") == "deploy.tar.gz":
|
||||||
|
print(r["id"])
|
||||||
|
print(r["tag_name"])
|
||||||
|
print(a["id"])
|
||||||
|
print(a["browser_download_url"])
|
||||||
|
sys.exit(0)
|
||||||
|
PY
|
||||||
|
) || true
|
||||||
|
|
||||||
|
if [ -z "$PARSED" ]; then
|
||||||
|
# 尚无可部署的发布版本,静默等待
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 计算产物指纹
|
RELEASE_ID=$(echo "$PARSED" | sed -n 1p)
|
||||||
CURRENT_SHA="$(sha256sum "$ARTIFACT" | awk '{print $1}')"
|
TAG=$(echo "$PARSED" | sed -n 2p)
|
||||||
LAST_SHA=""
|
ASSET_ID=$(echo "$PARSED" | sed -n 3p)
|
||||||
if [ -f "$STATE_FILE" ]; then
|
DOWNLOAD_URL=$(echo "$PARSED" | sed -n 4p)
|
||||||
LAST_SHA="$(cat "$STATE_FILE")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 无变化则跳过
|
# 幂等:已部署过该 release 则跳过
|
||||||
if [ "$CURRENT_SHA" = "$LAST_SHA" ]; then
|
LAST="$(cat "$STATE_FILE" 2>/dev/null || true)"
|
||||||
|
if [ "$LAST" = "$RELEASE_ID" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "发现新版本产物 sha256=${CURRENT_SHA:0:12},开始部署"
|
log "发现新版本 $TAG (release #$RELEASE_ID),开始拉取部署"
|
||||||
|
|
||||||
# 执行部署;成功后才记录指纹(失败时下次轮询会重试)
|
# 下载部署包(公开仓库可匿名;-L 跟随重定向)
|
||||||
if bash "$DEPLOY_SCRIPT" "$ARTIFACT" >> "$LOG_FILE" 2>&1; then
|
if ! curl -fsSL --connect-timeout 10 --max-time 300 \
|
||||||
|
"${AUTH_ARGS[@]}" \
|
||||||
|
"$DOWNLOAD_URL" -o "$WORK_DIR/deploy.tar.gz"; then
|
||||||
|
log "下载部署包失败,等待下次轮询重试"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 包结构预检(deploy.sh 内部还会再校验)
|
||||||
|
if ! tar -tzf "$WORK_DIR/deploy.tar.gz" | grep -q "^deploy/bin/server$"; then
|
||||||
|
log "部署包结构异常(缺少 deploy/bin/server),中止"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行部署;成功后才记录 release id(失败时下次轮询会重试)
|
||||||
|
if bash "$DEPLOY_SCRIPT" "$WORK_DIR/deploy.tar.gz" >> "$LOG_FILE" 2>&1; then
|
||||||
mkdir -p "$(dirname "$STATE_FILE")"
|
mkdir -p "$(dirname "$STATE_FILE")"
|
||||||
printf '%s' "$CURRENT_SHA" > "$STATE_FILE"
|
printf '%s' "$RELEASE_ID" > "$STATE_FILE"
|
||||||
log "部署成功"
|
log "部署成功: $TAG"
|
||||||
else
|
else
|
||||||
log "部署失败,保留旧指纹,等待下次轮询重试或人工介入"
|
log "部署失败($TAG),保留旧版本,等待下次轮询重试或人工介入"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -11,26 +11,99 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
# 专属运行器 lunar-node: docker://node:22-bookworm(注册于 doc79)
|
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
|
||||||
runs-on: lunar-node
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.neatcn.com/gouki/action-checkout@v4
|
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
|
||||||
with:
|
- name: Checkout
|
||||||
# Need recent commit subjects for the WeChat upload remark.
|
env:
|
||||||
fetch-depth: 20
|
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: Prepare Node toolchain
|
||||||
|
run: |
|
||||||
|
# 优先复用 runner 宿主机缓存的工具链(工作目录持久化)
|
||||||
|
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
|
||||||
|
# 清理旧缓存:Alpine busybox mv 遇已存在目录会合并而非替换,
|
||||||
|
# 残留的旧版 node 会污染环境导致 symbol not found
|
||||||
|
rm -rf "$TOOLDIR"
|
||||||
|
mkdir -p "$TOOLDIR"
|
||||||
|
# 探测 libc:Alpine(musl) 用 unofficial musl 构建,其余用官方 glibc 构建(tar.gz,busybox tar 不支持 xz)
|
||||||
|
# 关键教训:unofficial-builds 在亚洲带宽极低,wget --timeout 只限单次读取,
|
||||||
|
# 慢速断流仍返回成功,产生截断的 gzip(解压出损坏二进制报 symbol not found)。
|
||||||
|
# 必须用 sha256 硬校验,不匹配则换源重试。
|
||||||
|
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"
|
||||||
|
# 优先 npmmirror(国内/亚洲快),unofficial-builds 作为后备
|
||||||
|
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
|
||||||
|
# sha256 硬校验(busybox 有 sha256sum):拦截截断/错误页等假文件
|
||||||
|
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"
|
||||||
|
# musl 版 node 动态链接 libstdc++/libgcc_s,Alpine 精简容器缺失时补装
|
||||||
|
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 安装失败,node 可能无法启动"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# 验证二进制可执行,失败则清缓存并报错(避免坏缓存被后续 run 复用)
|
||||||
|
if ! "$TOOLDIR/node/bin/node" --version; then
|
||||||
|
echo "::error::Node 二进制无法执行,请检查下载源与 libc 匹配"
|
||||||
|
rm -rf "$TOOLDIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PATH=$TOOLDIR/node/bin:$PATH" >> "$GITEA_ENV"
|
||||||
|
|
||||||
- name: Verify Node runtime
|
- name: Verify Node runtime
|
||||||
run: node --version
|
run: node --version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: mini
|
working-directory: mini
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: mini
|
working-directory: mini
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Configure production API and app version
|
- name: Configure production API and app version
|
||||||
working-directory: mini
|
working-directory: mini
|
||||||
env:
|
env:
|
||||||
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }}
|
|
||||||
GITEA_SHA: ${{ gitea.sha }}
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
test -n "$MINIAPP_BUILD_NUMBER"
|
test -n "$MINIAPP_BUILD_NUMBER"
|
||||||
@@ -58,6 +131,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
echo "Version info injected into utils/version.js:"
|
echo "Version info injected into utils/version.js:"
|
||||||
cat utils/version.js
|
cat utils/version.js
|
||||||
|
|
||||||
- name: Materialize the upload key
|
- name: Materialize the upload key
|
||||||
env:
|
env:
|
||||||
WECHAT_CI_PRIVATE_KEY: ${{ secrets.WECHAT_CI_PRIVATE_KEY }}
|
WECHAT_CI_PRIVATE_KEY: ${{ secrets.WECHAT_CI_PRIVATE_KEY }}
|
||||||
@@ -65,6 +139,7 @@ jobs:
|
|||||||
test -n "$WECHAT_CI_PRIVATE_KEY"
|
test -n "$WECHAT_CI_PRIVATE_KEY"
|
||||||
umask 077
|
umask 077
|
||||||
printf '%s' "$WECHAT_CI_PRIVATE_KEY" > "$RUNNER_TEMP/wechat-ci.key"
|
printf '%s' "$WECHAT_CI_PRIVATE_KEY" > "$RUNNER_TEMP/wechat-ci.key"
|
||||||
|
|
||||||
- name: Upload WeChat development version
|
- name: Upload WeChat development version
|
||||||
working-directory: mini
|
working-directory: mini
|
||||||
env:
|
env:
|
||||||
@@ -72,7 +147,6 @@ jobs:
|
|||||||
MINIAPP_ROBOT: ${{ vars.MINIAPP_ROBOT }}
|
MINIAPP_ROBOT: ${{ vars.MINIAPP_ROBOT }}
|
||||||
# Prefer the version resolved before build so UI label matches WeChat upload.
|
# Prefer the version resolved before build so UI label matches WeChat upload.
|
||||||
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }}
|
|
||||||
WECHAT_CI_PRIVATE_KEY_PATH: ${{ runner.temp }}/wechat-ci.key
|
WECHAT_CI_PRIVATE_KEY_PATH: ${{ runner.temp }}/wechat-ci.key
|
||||||
MINIAPP_UPLOAD_RESULT: ${{ gitea.workspace }}/mini/ci-artifacts/upload-result.json
|
MINIAPP_UPLOAD_RESULT: ${{ gitea.workspace }}/mini/ci-artifacts/upload-result.json
|
||||||
MINIAPP_VERSION_LABEL: ${{ gitea.sha }}
|
MINIAPP_VERSION_LABEL: ${{ gitea.sha }}
|
||||||
@@ -90,30 +164,20 @@ jobs:
|
|||||||
node -e 'const {resolveVersion}=require("./ci/resolve-version.cjs"); console.log("Resolved Mini Program version:", resolveVersion(process.cwd()))'
|
node -e 'const {resolveVersion}=require("./ci/resolve-version.cjs"); console.log("Resolved Mini Program version:", resolveVersion(process.cwd()))'
|
||||||
node -e 'const {resolveUploadDesc}=require("./ci/resolve-upload-desc.cjs"); console.log("Resolved upload remark:", resolveUploadDesc({repoRoot:require("path").resolve(".."), versionLabel:process.env.MINIAPP_VERSION_LABEL}))'
|
node -e 'const {resolveUploadDesc}=require("./ci/resolve-upload-desc.cjs"); console.log("Resolved upload remark:", resolveUploadDesc({repoRoot:require("path").resolve(".."), versionLabel:process.env.MINIAPP_VERSION_LABEL}))'
|
||||||
# Normalize PEM newlines that may be flattened when stored in Secrets.
|
# Normalize PEM newlines that may be flattened when stored in Secrets.
|
||||||
python3 - <<'PY'
|
# 用 node 处理(部分 runner 无 python3)
|
||||||
from pathlib import Path
|
node - <<'JS'
|
||||||
import os
|
const fs = require("fs");
|
||||||
key_path = Path(os.environ["WECHAT_CI_PRIVATE_KEY_PATH"])
|
const p = process.env.WECHAT_CI_PRIVATE_KEY_PATH;
|
||||||
text = key_path.read_text()
|
let t = fs.readFileSync(p, "utf8");
|
||||||
if "\\n" in text and "BEGIN" in text:
|
if (t.includes("\\n") && t.includes("BEGIN")) t = t.split("\\n").join("\n");
|
||||||
text = text.replace("\\n", "\n")
|
t = t.split("\r\n").join("\n").trim() + "\n";
|
||||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
fs.writeFileSync(p, t);
|
||||||
key_path.write_text(text)
|
if (!/PRIVATE KEY/.test(t)) { console.error("WECHAT_CI_PRIVATE_KEY is not a PEM private key"); process.exit(1); }
|
||||||
content = key_path.read_text()
|
console.log("private_key_lines=", t.split("\n").length);
|
||||||
assert "BEGIN" in content and "PRIVATE KEY" in content, "WECHAT_CI_PRIVATE_KEY is not a PEM private key"
|
JS
|
||||||
print("private_key_lines=", len(content.splitlines()))
|
|
||||||
PY
|
|
||||||
node ci/upload-ci.cjs
|
node ci/upload-ci.cjs
|
||||||
test -s ci-artifacts/upload-result.json
|
test -s ci-artifacts/upload-result.json
|
||||||
cat ci-artifacts/upload-result.json
|
cat ci-artifacts/upload-result.json
|
||||||
- name: Persist upload result on runner host
|
|
||||||
run: |
|
|
||||||
test -s mini/ci-artifacts/upload-result.json
|
|
||||||
test -d /ci-artifacts
|
|
||||||
cp mini/ci-artifacts/upload-result.json "/ci-artifacts/miniapp-upload-${{ gitea.sha }}.json"
|
|
||||||
cp mini/ci-artifacts/upload-result.json /ci-artifacts/miniapp-upload-latest.json
|
|
||||||
ls -la /ci-artifacts/miniapp-upload-latest.json
|
|
||||||
echo "Upload result saved on runner host: /opt/lunar/ci-artifacts/miniapp-upload-latest.json"
|
|
||||||
|
|
||||||
- name: Send success notification
|
- name: Send success notification
|
||||||
if: success()
|
if: success()
|
||||||
@@ -123,7 +187,7 @@ jobs:
|
|||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="$(tr -d '\n' < mini/ci-artifacts/resolved-version.txt)"
|
VERSION="$(tr -d '\n' < mini/ci-artifacts/resolved-version.txt)"
|
||||||
bash .gitea/scripts/notify.sh success "小程序开发版上传成功" "版本: ${VERSION} 上传结果已保存到: /opt/lunar/ci-artifacts/miniapp-upload-latest.json"
|
bash .gitea/scripts/notify.sh success "小程序开发版上传成功" "版本: ${VERSION}" || echo "通知发送失败,不影响主流程"
|
||||||
|
|
||||||
- name: Send failure notification
|
- name: Send failure notification
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -134,7 +198,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bash .gitea/scripts/notify.sh failure \
|
bash .gitea/scripts/notify.sh failure \
|
||||||
"小程序开发版上传失败" \
|
"小程序开发版上传失败" \
|
||||||
"请检查 Actions 日志了解失败原因"
|
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
|
||||||
|
|
||||||
- name: Remove temporary credentials
|
- name: Remove temporary credentials
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Deploy Server
|
name: Build and Publish Server
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -10,23 +10,63 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
# 专属运行器:docker://golang:1.22-bookworm(注册于 doc79)
|
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
|
||||||
runs-on: lunar-ci
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.neatcn.com/gouki/action-checkout@v4
|
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
|
||||||
with:
|
- name: Checkout
|
||||||
fetch-depth: 20
|
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: Verify Go runtime
|
- name: Ensure curl available
|
||||||
run: go version
|
run: |
|
||||||
|
# 精简 runner(Alpine 等)可能未预装 curl,发布 release 需要它
|
||||||
|
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: |
|
||||||
|
# 优先复用 runner 宿主机缓存的工具链(工作目录持久化)
|
||||||
|
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: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
run: go mod download
|
run: |
|
||||||
|
export HOME="$RUNNER_WORKSPACE"
|
||||||
|
"$LUNAR_GO" mod download
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: server
|
working-directory: server
|
||||||
run: go test ./...
|
run: |
|
||||||
|
export HOME="$RUNNER_WORKSPACE"
|
||||||
|
"$LUNAR_GO" test ./...
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
working-directory: server
|
working-directory: server
|
||||||
@@ -34,33 +74,31 @@ jobs:
|
|||||||
GITEA_SHA: ${{ gitea.sha }}
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
|
export HOME="$RUNNER_WORKSPACE"
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
|
|
||||||
# 生成版本号(使用构建编号)
|
# 生成版本号(基线 1.1 + 构建编号,与小程序版本段对齐)
|
||||||
VERSION="1.0.${GITEA_RUN_NUMBER}"
|
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
COMMIT_SHORT="${GITEA_SHA:0:7}"
|
COMMIT_SHORT="${GITEA_SHA:0:7}"
|
||||||
|
|
||||||
echo "Building server version: $VERSION"
|
echo "Building server version: $VERSION"
|
||||||
echo "Build time: $BUILD_TIME"
|
|
||||||
echo "Commit SHA: $COMMIT_SHORT"
|
|
||||||
|
|
||||||
# 使用 ldflags 注入版本信息
|
# ldflags 注入版本信息;目标平台为 doc79(linux/amd64)
|
||||||
go build -ldflags "\
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
|
||||||
-X main.Version=$VERSION \
|
-X main.Version=$VERSION \
|
||||||
-X main.BuildTime=$BUILD_TIME \
|
-X main.BuildTime=$BUILD_TIME \
|
||||||
-X main.CommitSha=$COMMIT_SHORT" \
|
-X main.CommitSha=$COMMIT_SHORT" \
|
||||||
-o bin/server cmd/main.go
|
-o bin/server cmd/main.go
|
||||||
|
|
||||||
# 同时保存版本信息到文件(用于部署时读取)
|
# 版本元数据随包分发,便于服务器侧追溯
|
||||||
cat > bin/version.env <<EOF
|
cat > bin/version.env <<EOF
|
||||||
APP_VERSION=$VERSION
|
APP_VERSION=$VERSION
|
||||||
APP_BUILD_TIME=$BUILD_TIME
|
APP_BUILD_TIME=$BUILD_TIME
|
||||||
APP_COMMIT_SHA=$COMMIT_SHORT
|
APP_COMMIT_SHA=$COMMIT_SHORT
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "Version info saved to bin/version.env:"
|
|
||||||
cat bin/version.env
|
cat bin/version.env
|
||||||
|
# 注意:不写入 GITEA_ENV,避免跨步骤/跨运行残留值干扰发布步骤
|
||||||
|
|
||||||
- name: Create deployment package
|
- name: Create deployment package
|
||||||
working-directory: server
|
working-directory: server
|
||||||
@@ -74,15 +112,76 @@ jobs:
|
|||||||
cp -r web/ deploy/web/
|
cp -r web/ deploy/web/
|
||||||
fi
|
fi
|
||||||
tar -czf deploy.tar.gz deploy/
|
tar -czf deploy.tar.gz deploy/
|
||||||
|
sha256sum deploy.tar.gz
|
||||||
|
|
||||||
- name: Persist deployment package on runner host
|
- 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: |
|
run: |
|
||||||
test -f server/deploy.tar.gz
|
set -e
|
||||||
test -d /ci-artifacts
|
# 自行计算版本号;绝不复用环境中的 SERVER_VERSION(GITEA_ENV 会跨步骤持久化,
|
||||||
cp server/deploy.tar.gz "/ci-artifacts/server-deploy-${{ gitea.sha }}.tar.gz"
|
# 可能残留早期运行的旧值,曾导致构建 1.1.48 却发布成 1.0.48)
|
||||||
cp server/deploy.tar.gz /ci-artifacts/server-deploy-latest.tar.gz
|
unset SERVER_VERSION
|
||||||
ls -la /ci-artifacts/server-deploy-latest.tar.gz
|
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||||
echo "Deployment package saved on runner host: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz"
|
TAG="server-v${VERSION}"
|
||||||
|
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
|
||||||
|
# 优先用仓库 Secret 令牌(GITEA_TOKEN 内建令牌在部分 runner 版本不注入)
|
||||||
|
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
|
||||||
|
test -n "$TOKEN"
|
||||||
|
AUTH="Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
# 幂等:同 tag 已存在则先删除(重跑场景);不依赖 python3,纯 grep/sed 解析
|
||||||
|
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\":\"server $VERSION\",\"target_commitish\":\"main\",\"body\":\"CI 自动构建,commit ${GITEA_SHA:0:7}。doc79 由 deploy-watch.sh 拉取本附件完成部署。\"}" \
|
||||||
|
"$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/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 releases (keep latest 10)
|
||||||
|
env:
|
||||||
|
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# 只保留最近 10 个 server-v* 发布,避免附件无限堆积占满磁盘
|
||||||
|
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
|
||||||
|
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
|
||||||
|
AUTH="Authorization: token $TOKEN"
|
||||||
|
KEEP=10
|
||||||
|
|
||||||
|
# Gitea Release JSON 中 id 与 tag_name 相邻且按新到旧排序;
|
||||||
|
# 附件随 release 删除一并清理
|
||||||
|
PAIRS=$(curl -sS -H "$AUTH" "$API/releases?limit=50" \
|
||||||
|
| grep -oE '"id":[0-9]+,"tag_name":"server-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"
|
||||||
|
# 同步删除自动创建的 tag,避免 tag 堆积
|
||||||
|
curl -sS -X DELETE -H "$AUTH" "$API/tags/$RTAG" -o /dev/null -w "删除 tag $RTAG: HTTP %{http_code}\n"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Send success notification
|
- name: Send success notification
|
||||||
if: success()
|
if: success()
|
||||||
@@ -92,10 +191,10 @@ jobs:
|
|||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="1.0.${GITEA_RUN_NUMBER}"
|
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||||
bash .gitea/scripts/notify.sh success \
|
bash .gitea/scripts/notify.sh success \
|
||||||
"后端构建成功" \
|
"后端构建发布成功" \
|
||||||
"版本: v${VERSION}\n产物: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz\n部署由服务器侧 watcher 自动拉取完成"
|
"版本: v${VERSION}\n产物: releases/tag/server-v${VERSION}\ndoc79 将自动拉取部署" || echo "通知发送失败,不影响主流程"
|
||||||
|
|
||||||
- name: Send failure notification
|
- name: Send failure notification
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -106,7 +205,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bash .gitea/scripts/notify.sh failure \
|
bash .gitea/scripts/notify.sh failure \
|
||||||
"后端构建失败" \
|
"后端构建失败" \
|
||||||
"请检查 Actions 日志了解失败原因"
|
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"pages/divination/divination",
|
"pages/divination/divination",
|
||||||
"pages/solar-terms/solar-terms",
|
"pages/solar-terms/solar-terms",
|
||||||
"pages/settings/settings",
|
"pages/settings/settings",
|
||||||
|
"pages/preferences/preferences",
|
||||||
|
"pages/bookmarks/bookmarks",
|
||||||
|
"pages/birth-profiles/birth-profiles",
|
||||||
"pages/wish-tree/wish-tree",
|
"pages/wish-tree/wish-tree",
|
||||||
"pages/wish-detail/wish-detail",
|
"pages/wish-detail/wish-detail",
|
||||||
"pages/wiki/wiki"
|
"pages/wiki/wiki"
|
||||||
|
|||||||
@@ -45,19 +45,22 @@ function parseSemverParts(version) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode a monotonic CI build number into WeChat x.y.z.
|
* Encode a monotonic CI build number into WeChat x.y.z.
|
||||||
* Keeps package.json major; puts build into minor.patch with base 100.
|
* Keeps package.json major.minor as the baseline; build number maps to patch.
|
||||||
* Examples (major=1): build 99 → 1.0.99, build 100 → 1.1.0, build 101 → 1.1.1
|
* Examples (base 1.1.0): build 45 → 1.1.45, build 999 → 1.1.999
|
||||||
|
* (patch wraps every 1000 builds; bump package.json minor before that)
|
||||||
*/
|
*/
|
||||||
function versionFromBuildNumber(major, buildNumber) {
|
function versionFromBuildNumber(major, minor, buildNumber) {
|
||||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||||
throw new Error("MINIAPP_BUILD_NUMBER must be a positive integer.");
|
throw new Error("MINIAPP_BUILD_NUMBER must be a positive integer.");
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(major) || major < 0 || major > 999) {
|
if (!Number.isInteger(major) || major < 0 || major > 999) {
|
||||||
throw new Error("package.json major version must be an integer 0-999.");
|
throw new Error("package.json major version must be an integer 0-999.");
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(minor) || minor < 0 || minor > 999) {
|
||||||
|
throw new Error("package.json minor version must be an integer 0-999.");
|
||||||
|
}
|
||||||
|
|
||||||
const minor = Math.floor(buildNumber / 100) % 1000;
|
const patch = buildNumber % 1000;
|
||||||
const patch = buildNumber % 100;
|
|
||||||
return `${major}.${minor}.${patch}`;
|
return `${major}.${minor}.${patch}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +73,12 @@ function resolveVersion(projectPath, env = process.env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const packageVersion = readPackageVersion(projectPath);
|
const packageVersion = readPackageVersion(projectPath);
|
||||||
const { major } = parseSemverParts(packageVersion);
|
const { major, minor } = parseSemverParts(packageVersion);
|
||||||
|
|
||||||
const buildRaw = (env.MINIAPP_BUILD_NUMBER || "").trim();
|
const buildRaw = (env.MINIAPP_BUILD_NUMBER || "").trim();
|
||||||
if (buildRaw) {
|
if (buildRaw) {
|
||||||
const buildNumber = Number.parseInt(buildRaw, 10);
|
const buildNumber = Number.parseInt(buildRaw, 10);
|
||||||
return versionFromBuildNumber(major, buildNumber);
|
return versionFromBuildNumber(major, minor, buildNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy fixed override — prefer not to use; keeps version stuck across uploads.
|
// Legacy fixed override — prefer not to use; keeps version stuck across uploads.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lunar-mini",
|
"name": "lunar-mini",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "祈福小助手 - 万年历微信小程序",
|
"description": "祈福小助手 - 万年历微信小程序",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+140
-52
@@ -1,46 +1,33 @@
|
|||||||
const { birthInfoToBazi, analyzeElementBalance } = require('../../utils/core/index.js');
|
const { birthInfoToBazi, analyzeElementBalance } = require('../../utils/core/index.js');
|
||||||
|
const birthProfile = require('../../utils/birth-profile.js');
|
||||||
|
|
||||||
Page({
|
// 空态演示用的示例生辰(虚构,仅作排盘样式展示)
|
||||||
data: {
|
const DEMO_BIRTH = { year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male', ziSect: 'lateZiNextDay' };
|
||||||
gender: 'male',
|
|
||||||
birthDate: '1990-01-01',
|
|
||||||
birthTime: '12:00',
|
|
||||||
ziSect: 'lateZiNextDay',
|
|
||||||
bazi: null,
|
|
||||||
pillars: [],
|
|
||||||
elements: [],
|
|
||||||
elementAnalysis: {}
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoad() {
|
// 五行 → 样式类名
|
||||||
// 尝试从本地存储恢复
|
const ELEMENT_CLASS = { '木': 'el-wood', '火': 'el-fire', '土': 'el-earth', '金': 'el-metal', '水': 'el-water' };
|
||||||
const saved = wx.getStorageSync('lunar-bazi-input');
|
|
||||||
if (saved) {
|
|
||||||
this.setData(saved);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setGender(e) {
|
// 八字知识词条:逐字段详细说明
|
||||||
this.setData({ gender: e.currentTarget.dataset.g });
|
const GLOSSARY = [
|
||||||
},
|
{ term: '四柱八字', desc: '用天干地支记录出生年、月、日、时的方法,年、月、日、时各一对干支,合称"四柱",共八个字,故称"八字"。它源于古代干支纪时体系,是传统历法文化的一部分。' },
|
||||||
|
{ term: '天干地支', desc: '天干共十个(甲乙丙丁戊己庚辛壬癸),地支共十二个(子丑寅卯辰巳午未申酉戌亥),是古人用于纪年、纪月、纪日、纪时的符号系统,也是传统历法的基础。' },
|
||||||
setZiSect(e) {
|
{ term: '年柱', desc: '出生年份的干支。传统上以立春为界划分年份(立春前出生按上一年)。年柱在民俗说法中对应祖辈与少年时期。' },
|
||||||
this.setData({ ziSect: e.currentTarget.dataset.s });
|
{ term: '月柱', desc: '出生月份的干支。月份以节气划分(如立春至惊蛰为正月),并非公历或农历的自然月。民俗说法中对应父母与青年时期。' },
|
||||||
},
|
{ term: '日柱', desc: '出生当日的干支,按六十甲子循环纪日。日柱的天干代表本人(日主),是整个命盘分析的核心。' },
|
||||||
|
{ term: '时柱', desc: '出生时辰的干支。古人将一昼夜分为十二个时辰,每个时辰合今两小时(如子时为 23:00-01:00)。' },
|
||||||
onDateChange(e) {
|
{ term: '日主', desc: '日柱的天干,代表命主本人。日主的五行属性(木火土金水)是分析命盘五行强弱与十神关系的出发点。' },
|
||||||
this.setData({ birthDate: e.detail.value });
|
{ term: '十神', desc: '以日主为参照,按五行生克关系对其他干支的十种称谓:比肩、劫财、食神、伤官、偏财、正财、七杀、正官、偏印、正印。它是古代的一种关系分类符号,用于描述干支之间的生克比和。' },
|
||||||
},
|
{ term: '五行', desc: '木、火、土、金、水五种基本元素,源于古代哲学,认为万物由五行构成并相生相克(木生火、火生土、土生金、金生水、水生木;木克土、土克水、水克火、火克金、金克木)。五行分析统计八字中各元素的数量与强弱。' },
|
||||||
|
{ term: '藏干', desc: '又称"人元",指每个地支内部按传统规则所含的一个至三个天干。例如寅中藏甲、丙、戊。排盘时作为辅助信息列出。' },
|
||||||
onTimeChange(e) {
|
{ term: '纳音', desc: '将六十甲子两两配对归入五行所得的三十种称谓,如"甲子乙丑海中金"、"丙寅丁卯炉中火"。年命纳音(年柱纳音)在民俗中常被用来称呼人的"命",如"金命"、"火命"。' },
|
||||||
this.setData({ birthTime: e.detail.value });
|
{ term: '空亡', desc: '六十甲子分为六旬,每旬十个干支配十二地支,余下两个地支无天干相配,称为该旬"空亡"。这是干支纪法本身的结构性概念,此处仅作历法知识展示。' },
|
||||||
},
|
{ term: '大运', desc: '传统上以月柱为基础、按十年一换的节奏推演出的干支序列,用来描述人生不同阶段的节律。属于传统民俗推演方法,供文化了解。' },
|
||||||
|
{ term: '流年', desc: '即每一年的干支(如 2026 年为丙午年)。传统做法将流年与本命干支对照,观察逐年的变化关系。' },
|
||||||
calculate() {
|
{ term: '子时流派', desc: '子时(23:00-01:00)横跨两天,晚子时(23:00-24:00)出生者的日柱按当天还是次日起算,历代流派说法不一。可在"我的-设置"中选择。' }
|
||||||
const { gender, birthDate, birthTime, ziSect } = this.data;
|
];
|
||||||
const [year, month, day] = birthDate.split('-').map(Number);
|
|
||||||
const [hour, minute] = birthTime.split(':').map(Number);
|
|
||||||
|
|
||||||
|
/** 根据出生信息构建排盘展示数据 */
|
||||||
|
function buildChart({ year, month, day, hour, minute, gender, ziSect }) {
|
||||||
const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect });
|
const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect });
|
||||||
const elementAnalysis = analyzeElementBalance(bazi.eightChar);
|
const elementAnalysis = analyzeElementBalance(bazi.eightChar);
|
||||||
|
|
||||||
@@ -49,30 +36,131 @@ Page({
|
|||||||
{ label: '月柱', ...bazi.eightChar.monthPillar },
|
{ label: '月柱', ...bazi.eightChar.monthPillar },
|
||||||
{ label: '日柱', ...bazi.eightChar.dayPillar },
|
{ label: '日柱', ...bazi.eightChar.dayPillar },
|
||||||
{ label: '时柱', ...bazi.eightChar.hourPillar }
|
{ label: '时柱', ...bazi.eightChar.hourPillar }
|
||||||
];
|
].map(p => ({
|
||||||
|
label: p.label,
|
||||||
|
ganzhi: p.ganzhi,
|
||||||
|
tenStar: p.tenStar || '日主',
|
||||||
|
heavenStem: p.heavenStem,
|
||||||
|
earthBranch: p.earthBranch,
|
||||||
|
stemClass: ELEMENT_CLASS[p.elementStem] || '',
|
||||||
|
branchClass: ELEMENT_CLASS[p.elementBranch] || '',
|
||||||
|
hideStems: (p.hideStems || []).map(h => h.stem + (h.tenStar ? `(${h.tenStar})` : '')).join(' '),
|
||||||
|
nayin: p.nayin
|
||||||
|
}));
|
||||||
|
|
||||||
const total = elementAnalysis.total || 1;
|
const total = elementAnalysis.total || 1;
|
||||||
const elements = [
|
const elements = [
|
||||||
{ name: '木', count: elementAnalysis.wood, percent: Math.round(elementAnalysis.wood / total * 100) },
|
{ name: '木', count: elementAnalysis.wood, percent: Math.round(elementAnalysis.wood / total * 100), cls: 'el-wood' },
|
||||||
{ name: '火', count: elementAnalysis.fire, percent: Math.round(elementAnalysis.fire / total * 100) },
|
{ name: '火', count: elementAnalysis.fire, percent: Math.round(elementAnalysis.fire / total * 100), cls: 'el-fire' },
|
||||||
{ name: '土', count: elementAnalysis.earth, percent: Math.round(elementAnalysis.earth / total * 100) },
|
{ name: '土', count: elementAnalysis.earth, percent: Math.round(elementAnalysis.earth / total * 100), cls: 'el-earth' },
|
||||||
{ name: '金', count: elementAnalysis.metal, percent: Math.round(elementAnalysis.metal / total * 100) },
|
{ name: '金', count: elementAnalysis.metal, percent: Math.round(elementAnalysis.metal / total * 100), cls: 'el-metal' },
|
||||||
{ name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100) }
|
{ name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100), cls: 'el-water' }
|
||||||
];
|
];
|
||||||
|
|
||||||
this.setData({ bazi, pillars, elements, elementAnalysis });
|
return { bazi, pillars, elements, elementAnalysis };
|
||||||
|
}
|
||||||
|
|
||||||
// 保存输入
|
Page({
|
||||||
wx.setStorageSync('lunar-bazi-input', { gender, birthDate, birthTime, ziSect });
|
data: {
|
||||||
// 保存八字供运势页使用
|
isDemo: true,
|
||||||
wx.setStorageSync('lunar-user-bazi', bazi);
|
profiles: [],
|
||||||
|
activeProfileId: '',
|
||||||
|
profile: null,
|
||||||
|
bazi: null,
|
||||||
|
pillars: [],
|
||||||
|
elements: [],
|
||||||
|
elementAnalysis: {},
|
||||||
|
glossary: GLOSSARY,
|
||||||
|
showGlossary: false
|
||||||
},
|
},
|
||||||
|
|
||||||
reset() {
|
onShow() {
|
||||||
this.setData({ bazi: null });
|
this.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
const render = () => {
|
||||||
|
const profiles = birthProfile.getProfiles();
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
this.showDemo();
|
||||||
|
} else {
|
||||||
|
this.showProfile(profiles, birthProfile.getActiveProfile());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 已登录时先同步云端档案,失败静默回退本地
|
||||||
|
if (birthProfile.isLoggedIn()) {
|
||||||
|
birthProfile.syncFromServer().then(render).catch(render);
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 空态:展示示例排盘 */
|
||||||
|
showDemo() {
|
||||||
|
const chart = buildChart(DEMO_BIRTH);
|
||||||
|
this.setData({
|
||||||
|
isDemo: true,
|
||||||
|
profiles: [],
|
||||||
|
activeProfileId: '',
|
||||||
|
profile: null,
|
||||||
|
showGlossary: true,
|
||||||
|
...chart
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 已绑定:展示所选档案的排盘 */
|
||||||
|
showProfile(profiles, profile) {
|
||||||
|
if (!profile) return;
|
||||||
|
const [year, month, day] = profile.birthday.split('-').map(Number);
|
||||||
|
const [hour, minute] = profile.birthTime.split(':').map(Number);
|
||||||
|
const settings = wx.getStorageSync('lunar-settings') || {};
|
||||||
|
const chart = buildChart({
|
||||||
|
year, month, day, hour, minute,
|
||||||
|
gender: profile.gender,
|
||||||
|
ziSect: settings.ziHourSect || 'lateZiNextDay'
|
||||||
|
});
|
||||||
|
// 保存八字供运势页使用
|
||||||
|
wx.setStorageSync('lunar-user-bazi', chart.bazi);
|
||||||
|
this.setData({
|
||||||
|
isDemo: false,
|
||||||
|
profiles,
|
||||||
|
activeProfileId: profile.id,
|
||||||
|
profile,
|
||||||
|
showGlossary: false,
|
||||||
|
...chart
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectProfile(e) {
|
||||||
|
const id = e.currentTarget.dataset.id;
|
||||||
|
const profile = this.data.profiles.find(p => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
birthProfile.setActiveProfile(id);
|
||||||
|
this.showProfile(this.data.profiles, profile);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleGlossary() {
|
||||||
|
this.setData({ showGlossary: !this.data.showGlossary });
|
||||||
|
},
|
||||||
|
|
||||||
|
goSettings() {
|
||||||
|
wx.navigateTo({ url: '/pages/birth-profiles/birth-profiles' });
|
||||||
},
|
},
|
||||||
|
|
||||||
goFortune() {
|
goFortune() {
|
||||||
wx.switchTab({ url: '/pages/fortune/fortune' });
|
wx.navigateTo({ url: '/pages/fortune/fortune' });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '八字排盘 - 生辰八字查询',
|
||||||
|
path: '/pages/bazi/bazi'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '八字排盘 - 生辰八字查询'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+100
-47
@@ -1,51 +1,83 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 输入表单 -->
|
<!-- 文化声明 -->
|
||||||
<view class="card" wx:if="{{!bazi}}">
|
<view class="culture-card">
|
||||||
<view class="section-title">输入出生信息</view>
|
<view class="culture-title">八字 · 传统历法文化</view>
|
||||||
|
<view class="culture-text">八字(四柱)是中国传统文化的重要组成部分,源自古人观测天象、制定历法的干支纪时体系与五行哲学思想,属于传统民俗文化与历法知识范畴,不是封建迷信。本页面仅按传统历法规则进行排盘展示与知识科普,内容供传统文化学习与娱乐参考,不构成任何人生决策建议。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="form-item">
|
<!-- 空态:示例提示 + 绑定入口 -->
|
||||||
<text class="form-label">性别</text>
|
<block wx:if="{{isDemo}}">
|
||||||
<view class="flex gap-2">
|
<view class="card empty-card">
|
||||||
<view class="btn-outline {{gender === 'male' ? 'active' : ''}}" bindtap="setGender" data-g="male">男</view>
|
<view class="empty-title">你还没有绑定生辰信息</view>
|
||||||
<view class="btn-outline {{gender === 'female' ? 'active' : ''}}" bindtap="setGender" data-g="female">女</view>
|
<view class="empty-desc">下方是一份示例排盘,帮助你了解八字页面的内容。在个人中心可免费绑定 3 个出生年月(本人、家人),绑定后即可查看专属排盘。</view>
|
||||||
|
<view class="btn-primary text-center mt-2" bindtap="goSettings">去绑定我的生辰</view>
|
||||||
|
</view>
|
||||||
|
<view class="demo-banner">
|
||||||
|
<text class="demo-tag">示例</text>
|
||||||
|
<text class="demo-text">以下排盘基于虚构生辰(1990-08-08 10:30 男),仅演示页面效果</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- 已绑定:档案选择 -->
|
||||||
|
<view class="card" wx:if="{{!isDemo}}">
|
||||||
|
<view class="section-title">我的生辰</view>
|
||||||
|
<view class="flex gap-2 profile-chips">
|
||||||
|
<view
|
||||||
|
class="profile-chip {{item.id === activeProfileId ? 'active' : ''}}"
|
||||||
|
wx:for="{{profiles}}"
|
||||||
|
wx:key="id"
|
||||||
|
bindtap="selectProfile"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
>{{item.name}}</view>
|
||||||
|
<view class="profile-chip add-chip" bindtap="goSettings">管理 ›</view>
|
||||||
|
</view>
|
||||||
|
<view class="text-xs text-muted mt-1">
|
||||||
|
{{profile.name}} · {{profile.gender === 'female' ? '女' : '男'}} · 公历 {{profile.birthday}} {{profile.birthTime}}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="form-item">
|
<!-- 四柱八字 -->
|
||||||
<text class="form-label">出生日期</text>
|
|
||||||
<picker mode="date" value="{{birthDate}}" bindchange="onDateChange">
|
|
||||||
<view class="picker-input">{{birthDate}}</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="form-item">
|
|
||||||
<text class="form-label">出生时间</text>
|
|
||||||
<picker mode="time" value="{{birthTime}}" bindchange="onTimeChange">
|
|
||||||
<view class="picker-input">{{birthTime}}</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="form-item">
|
|
||||||
<text class="form-label">子时流派</text>
|
|
||||||
<view class="flex gap-2">
|
|
||||||
<view class="btn-outline {{ziSect === 'lateZiNextDay' ? 'active' : ''}}" bindtap="setZiSect" data-s="lateZiNextDay">晚子时日柱算次日</view>
|
|
||||||
<view class="btn-outline {{ziSect === 'lateZiSameDay' ? 'active' : ''}}" bindtap="setZiSect" data-s="lateZiSameDay">晚子时日柱算当日</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="btn-primary mt-2" bindtap="calculate">排盘</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 八字结果 -->
|
|
||||||
<block wx:if="{{bazi}}">
|
|
||||||
<!-- 四柱 -->
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">四柱八字</view>
|
<view class="section-title">四柱八字</view>
|
||||||
<view class="grid grid-cols-4 gap-2">
|
<view class="grid grid-cols-4 gap-2">
|
||||||
<view class="pillar" wx:for="{{pillars}}" wx:key="label">
|
<view class="pillar" wx:for="{{pillars}}" wx:key="label">
|
||||||
<text class="pillar-label">{{item.label}}</text>
|
<text class="pillar-label">{{item.label}}</text>
|
||||||
<text class="pillar-ganzhi">{{item.ganzhi}}</text>
|
<text class="pillar-ten">{{item.tenStar}}</text>
|
||||||
<text class="pillar-ten">{{item.tenStar || '日主'}}</text>
|
<text class="pillar-char {{item.stemClass}}">{{item.heavenStem}}</text>
|
||||||
|
<text class="pillar-char {{item.branchClass}}">{{item.earthBranch}}</text>
|
||||||
|
<text class="pillar-hide" wx:if="{{item.hideStems}}">藏 {{item.hideStems}}</text>
|
||||||
|
<text class="pillar-nayin">{{item.nayin}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 命盘概览 -->
|
||||||
|
<view class="card">
|
||||||
|
<view class="section-title">命盘概览</view>
|
||||||
|
<view class="meta-grid">
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">日主</text>
|
||||||
|
<text class="meta-value">{{bazi.eightChar.dayMaster}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">五行旺</text>
|
||||||
|
<text class="meta-value">{{elementAnalysis.dominant}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">五行弱</text>
|
||||||
|
<text class="meta-value">{{elementAnalysis.weakest}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">空亡</text>
|
||||||
|
<text class="meta-value">{{bazi.eightChar.emptyBranches[0]}}{{bazi.eightChar.emptyBranches[1]}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">年柱纳音</text>
|
||||||
|
<text class="meta-value">{{pillars[0].nayin}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-item">
|
||||||
|
<text class="meta-label">五行均衡</text>
|
||||||
|
<text class="meta-value">{{elementAnalysis.isBalanced ? '较均衡' : '有偏旺'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -57,19 +89,17 @@
|
|||||||
<view class="element-item" wx:for="{{elements}}" wx:key="name">
|
<view class="element-item" wx:for="{{elements}}" wx:key="name">
|
||||||
<text class="element-name">{{item.name}}</text>
|
<text class="element-name">{{item.name}}</text>
|
||||||
<view class="element-track">
|
<view class="element-track">
|
||||||
<view class="element-fill" style="width: {{item.percent}}%"></view>
|
<view class="element-fill {{item.cls}}" style="width: {{item.percent}}%"></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="element-count">{{item.count}}</text>
|
<text class="element-count">{{item.count}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="mt-2 text-sm text-muted">
|
<view class="mt-2 text-xs text-muted">按天干、地支计 1 分,藏干计 0.5 分;得分越高代表该五行在命盘中越突出。</view>
|
||||||
日主:{{bazi.eightChar.dayMaster}} | dominant: {{elementAnalysis.dominant}} | weakest: {{elementAnalysis.weakest}}
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 大运 -->
|
<!-- 大运 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">大运</view>
|
<view class="section-title">大运(十年一步)</view>
|
||||||
<scroll-view scroll-x class="scroll-x">
|
<scroll-view scroll-x class="scroll-x">
|
||||||
<view class="flex gap-2">
|
<view class="flex gap-2">
|
||||||
<view class="fortune-item" wx:for="{{bazi.decadeFortunes}}" wx:key="index">
|
<view class="fortune-item" wx:for="{{bazi.decadeFortunes}}" wx:key="index">
|
||||||
@@ -95,10 +125,33 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 操作 -->
|
<!-- 字段说明 -->
|
||||||
<view class="flex gap-2 mt-2">
|
<view class="card">
|
||||||
<view class="btn-outline flex-1 text-center" bindtap="reset">重新排盘</view>
|
<view class="glossary-header" bindtap="toggleGlossary">
|
||||||
<view class="btn-primary flex-1 text-center" bindtap="goFortune">看运势</view>
|
<text class="section-title glossary-title">这些字段是什么意思?</text>
|
||||||
|
<text class="glossary-toggle">{{showGlossary ? '收起' : '展开'}}</text>
|
||||||
|
</view>
|
||||||
|
<block wx:if="{{showGlossary}}">
|
||||||
|
<view class="glossary-item" wx:for="{{glossary}}" wx:key="term">
|
||||||
|
<text class="glossary-term">{{item.term}}</text>
|
||||||
|
<text class="glossary-desc">{{item.desc}}</text>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部操作 -->
|
||||||
|
<view class="flex gap-2 mt-2 bottom-actions">
|
||||||
|
<block wx:if="{{isDemo}}">
|
||||||
|
<view class="btn-primary flex-1 text-center" bindtap="goSettings">绑定生辰,查看我的排盘</view>
|
||||||
|
</block>
|
||||||
|
<block wx:else>
|
||||||
|
<view class="btn-outline flex-1 text-center" bindtap="goSettings">管理生辰</view>
|
||||||
|
<view class="btn-primary flex-1 text-center" bindtap="goFortune">看今日运势</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部免责声明 -->
|
||||||
|
<view class="disclaimer">
|
||||||
|
八字排盘为传统历法文化展示,内容仅供学习参考,请理性看待,切勿迷信。
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
+200
-23
@@ -9,35 +9,103 @@
|
|||||||
color: #C41E3A;
|
color: #C41E3A;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-item {
|
/* 文化声明卡片 */
|
||||||
margin-bottom: 24rpx;
|
.culture-card {
|
||||||
|
background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空态卡片 */
|
||||||
|
.empty-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #6B5E53;
|
color: #6B5E53;
|
||||||
margin-bottom: 8rpx;
|
line-height: 1.7;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-input {
|
/* 示例横幅 */
|
||||||
padding: 20rpx;
|
.demo-banner {
|
||||||
background: #FFFBF5;
|
display: flex;
|
||||||
border-radius: 12rpx;
|
align-items: center;
|
||||||
border: 1rpx solid #E8D5C4;
|
gap: 12rpx;
|
||||||
|
background: #FFF7E6;
|
||||||
|
border: 1rpx solid #F0D9A8;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline.active {
|
.demo-tag {
|
||||||
background: #C41E3A;
|
flex-shrink: 0;
|
||||||
|
background: #D99A06;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #C41E3A;
|
font-size: 20rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.demo-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8A6D1A;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 档案选择 */
|
||||||
|
.profile-chips {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chip {
|
||||||
|
padding: 10rpx 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
border: 2rpx solid #E8D5C4;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6B5E53;
|
||||||
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chip.active {
|
||||||
|
background: #C41E3A;
|
||||||
|
border-color: #C41E3A;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-chip {
|
||||||
|
border-style: dashed;
|
||||||
|
color: #9E8E7E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 四柱 */
|
||||||
.pillar {
|
.pillar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20rpx;
|
padding: 16rpx 8rpx;
|
||||||
background: #FFFBF5;
|
background: #FFFBF5;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
}
|
}
|
||||||
@@ -47,18 +115,69 @@
|
|||||||
color: #9E8E7E;
|
color: #9E8E7E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pillar-ganzhi {
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #C41E3A;
|
|
||||||
margin: 8rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-ten {
|
.pillar-ten {
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
color: #6B5E53;
|
color: #6B5E53;
|
||||||
|
margin: 4rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pillar-char {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar-hide {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar-nayin {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 五行配色 */
|
||||||
|
.el-wood { color: #2E7D32; }
|
||||||
|
.el-fire { color: #C41E3A; }
|
||||||
|
.el-earth { color: #8D6E63; }
|
||||||
|
.el-metal { color: #B8860B; }
|
||||||
|
.el-water { color: #1565C0; }
|
||||||
|
|
||||||
|
/* 命盘概览 */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: #FFFBF5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 16rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 五行分析 */
|
||||||
.element-bar {
|
.element-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -79,23 +198,29 @@
|
|||||||
.element-track {
|
.element-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 16rpx;
|
height: 16rpx;
|
||||||
background: #E8D5C4;
|
background: #F0E6DA;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.element-fill {
|
.element-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #C41E3A;
|
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.element-fill.el-wood { background: #2E7D32; }
|
||||||
|
.element-fill.el-fire { background: #C41E3A; }
|
||||||
|
.element-fill.el-earth { background: #8D6E63; }
|
||||||
|
.element-fill.el-metal { background: #B8860B; }
|
||||||
|
.element-fill.el-water { background: #1565C0; }
|
||||||
|
|
||||||
.element-count {
|
.element-count {
|
||||||
width: 60rpx;
|
width: 60rpx;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 大运/流年 */
|
||||||
.fortune-item {
|
.fortune-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -105,3 +230,55 @@
|
|||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
min-width: 160rpx;
|
min-width: 160rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 字段说明 */
|
||||||
|
.glossary-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-toggle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-item {
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #F0E6DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-term {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #C41E3A;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B5E53;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.bottom-actions {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
padding: 8rpx 0 24rpx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
const birthProfile = require('../../utils/birth-profile.js');
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loggedIn: false,
|
||||||
|
profiles: [],
|
||||||
|
activeProfileId: '',
|
||||||
|
maxFree: birthProfile.MAX_PROFILES,
|
||||||
|
canAdd: true,
|
||||||
|
showAddForm: false,
|
||||||
|
formName: '',
|
||||||
|
formGender: 'male',
|
||||||
|
formDate: '1990-01-01',
|
||||||
|
formTime: '12:00'
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.setData({ loggedIn: birthProfile.isLoggedIn() });
|
||||||
|
this.loadProfiles();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadProfiles() {
|
||||||
|
const render = () => {
|
||||||
|
const profiles = birthProfile.getProfiles();
|
||||||
|
const active = birthProfile.getActiveProfile();
|
||||||
|
this.setData({
|
||||||
|
profiles,
|
||||||
|
activeProfileId: active ? active.id : '',
|
||||||
|
canAdd: profiles.length < this.data.maxFree
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 已登录时先从服务端同步,失败静默回退本地
|
||||||
|
if (birthProfile.isLoggedIn()) {
|
||||||
|
birthProfile.syncFromServer().then(render).catch(render);
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAddForm() {
|
||||||
|
if (!this.data.showAddForm && !this.data.canAdd) {
|
||||||
|
wx.showToast({ title: `最多可免费绑定 ${this.data.maxFree} 个生辰`, icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setData({ showAddForm: !this.data.showAddForm });
|
||||||
|
},
|
||||||
|
|
||||||
|
onFormName(e) {
|
||||||
|
this.setData({ formName: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
setFormGender(e) {
|
||||||
|
this.setData({ formGender: e.currentTarget.dataset.g });
|
||||||
|
},
|
||||||
|
|
||||||
|
onFormDate(e) {
|
||||||
|
this.setData({ formDate: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
onFormTime(e) {
|
||||||
|
this.setData({ formTime: e.detail.value });
|
||||||
|
},
|
||||||
|
|
||||||
|
submitProfile() {
|
||||||
|
const { formName, formGender, formDate, formTime } = this.data;
|
||||||
|
if (!formDate || !formTime) {
|
||||||
|
wx.showToast({ title: '请选择出生日期和时间', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = birthProfile.addProfile({
|
||||||
|
name: formName.trim() || '未命名',
|
||||||
|
gender: formGender,
|
||||||
|
birthday: formDate,
|
||||||
|
birthTime: formTime
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
wx.showToast({ title: result.msg, icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 已登录时推送到服务端(静默失败,不影响本地)
|
||||||
|
birthProfile.pushProfileToServer(result.profile);
|
||||||
|
this.setData({ showAddForm: false, formName: '' });
|
||||||
|
wx.showToast({ title: '绑定成功', icon: 'success' });
|
||||||
|
this.loadProfiles();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeProfile(e) {
|
||||||
|
const id = e.currentTarget.dataset.id;
|
||||||
|
const profile = this.data.profiles.find(p => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除生辰',
|
||||||
|
content: `确定删除「${profile.name}」的生辰信息吗?`,
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
birthProfile.removeProfile(id);
|
||||||
|
birthProfile.removeProfileFromServer(profile);
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
this.loadProfiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 未登录时引导回个人中心登录
|
||||||
|
goLogin() {
|
||||||
|
wx.navigateBack();
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '生辰档案 - 老黄历',
|
||||||
|
path: '/pages/birth-profiles/birth-profiles'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '生辰档案 - 老黄历'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "生辰管理",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<view class="container">
|
||||||
|
<!-- 未登录提示 -->
|
||||||
|
<view class="card login-tip" wx:if="{{!loggedIn}}">
|
||||||
|
<text class="text-sm">当前未登录,生辰仅保存在本机</text>
|
||||||
|
<text class="text-xs text-muted block mt-1">登录后可云端同步,换设备不丢失</text>
|
||||||
|
<view class="btn-outline text-center mt-2" bindtap="goLogin">去登录</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 生辰管理 -->
|
||||||
|
<view class="card">
|
||||||
|
<view class="section-title flex justify-between items-center">
|
||||||
|
<text>生辰管理</text>
|
||||||
|
<text class="text-xs text-muted">{{profiles.length}}/{{maxFree}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="text-xs text-muted mb-2">八字排盘基于生辰信息,每位用户可免费绑定 {{maxFree}} 个出生年月(如本人、家人)。</view>
|
||||||
|
|
||||||
|
<view class="profile-list" wx:if="{{profiles.length > 0}}">
|
||||||
|
<view class="profile-item" wx:for="{{profiles}}" wx:key="id">
|
||||||
|
<view class="flex justify-between items-center">
|
||||||
|
<view>
|
||||||
|
<view class="flex items-center gap-1">
|
||||||
|
<text class="text-base font-medium">{{item.name}}</text>
|
||||||
|
<text class="badge badge-lucky" wx:if="{{item.id === activeProfileId}}">使用中</text>
|
||||||
|
</view>
|
||||||
|
<text class="text-xs text-muted block">{{item.gender === 'female' ? '女' : '男'}} · {{item.birthday}} {{item.birthTime}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="btn-ghost text-unlucky" bindtap="removeProfile" data-id="{{item.id}}">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="text-center text-muted p-2" wx:else>
|
||||||
|
<text>暂未绑定生辰,请到下方添加</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn-outline text-center mt-2" bindtap="toggleAddForm" wx:if="{{canAdd}}">
|
||||||
|
{{showAddForm ? '收起' : '+ 添加生辰'}}
|
||||||
|
</view>
|
||||||
|
<view class="text-center text-xs text-muted mt-2" wx:else>
|
||||||
|
免费名额已用完,可删除旧档案后添加
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加表单 -->
|
||||||
|
<block wx:if="{{showAddForm}}">
|
||||||
|
<view class="form-item mt-2">
|
||||||
|
<text class="form-label">备注名(如:本人、爸爸)</text>
|
||||||
|
<input class="form-input" placeholder="未命名" value="{{formName}}" bindinput="onFormName"/>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">性别</text>
|
||||||
|
<view class="flex gap-2">
|
||||||
|
<view class="btn-outline {{formGender === 'male' ? 'active' : ''}}" bindtap="setFormGender" data-g="male">男</view>
|
||||||
|
<view class="btn-outline {{formGender === 'female' ? 'active' : ''}}" bindtap="setFormGender" data-g="female">女</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">出生日期(公历)</text>
|
||||||
|
<picker mode="date" value="{{formDate}}" start="1900-01-01" end="2100-12-31" bindchange="onFormDate">
|
||||||
|
<view class="picker-input">{{formDate}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">出生时间</text>
|
||||||
|
<picker mode="time" value="{{formTime}}" bindchange="onFormTime">
|
||||||
|
<view class="picker-input">{{formTime}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="btn-primary text-center" bindtap="submitProfile">保存生辰</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.container {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tip {
|
||||||
|
border: 1rpx dashed #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 生辰档案 */
|
||||||
|
.profile-item {
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #E8D5C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加生辰表单 */
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B5E53;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 20rpx;
|
||||||
|
background: #FFFBF5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
border: 1rpx solid #E8D5C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-input {
|
||||||
|
padding: 20rpx;
|
||||||
|
background: #FFFBF5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
border: 1rpx solid #E8D5C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline.active {
|
||||||
|
background: #C41E3A;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-color: #C41E3A;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
bookmarks: []
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.loadBookmarks();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadBookmarks() {
|
||||||
|
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
||||||
|
this.setData({ bookmarks });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeBookmark(e) {
|
||||||
|
const date = e.currentTarget.dataset.date;
|
||||||
|
const bookmarks = this.data.bookmarks.filter(b => b.date !== date);
|
||||||
|
wx.setStorageSync('lunar-bookmarks', bookmarks);
|
||||||
|
this.setData({ bookmarks });
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点击收藏项跳转到当日详情
|
||||||
|
openDetail(e) {
|
||||||
|
const date = e.currentTarget.dataset.date;
|
||||||
|
wx.navigateTo({ url: `/pages/day-detail/day-detail?date=${date}` });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '我的收藏 - 老黄历',
|
||||||
|
path: '/pages/bookmarks/bookmarks'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '我的收藏 - 老黄历'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "我的收藏",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="card">
|
||||||
|
<view class="section-title">我的收藏</view>
|
||||||
|
<view class="bookmark-list" wx:if="{{bookmarks.length > 0}}">
|
||||||
|
<view class="bookmark-item" wx:for="{{bookmarks}}" wx:key="date" bindtap="openDetail" data-date="{{item.date}}">
|
||||||
|
<view class="flex justify-between items-center">
|
||||||
|
<view>
|
||||||
|
<text class="text-base font-medium">{{item.date}}</text>
|
||||||
|
<text class="text-xs text-muted block">{{item.lunarDate}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="btn-ghost text-unlucky" catchtap="removeBookmark" data-date="{{item.date}}">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty-tip" wx:else>
|
||||||
|
<text class="text-sm text-muted">暂无收藏</text>
|
||||||
|
<text class="text-xs text-muted block mt-1">在日期详情页点击「收藏」即可添加</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.container {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item {
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #E8D5C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
@@ -82,5 +82,16 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
noop() {}
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '万年历 - 农历公历对照查询',
|
||||||
|
path: '/pages/calendar/calendar'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '万年历 - 农历公历对照查询'
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,5 +39,25 @@ Page({
|
|||||||
|
|
||||||
goBazi() {
|
goBazi() {
|
||||||
wx.navigateTo({ url: '/pages/bazi/bazi' });
|
wx.navigateTo({ url: '/pages/bazi/bazi' });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
const { dayInfo } = this.data;
|
||||||
|
const title = dayInfo.lunarMonthName
|
||||||
|
? `${dayInfo.lunarMonthName}${dayInfo.lunarDayName} 黄历详情`
|
||||||
|
: '每日黄历详情';
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
path: `/pages/day-detail/day-detail?date=${this.data.date}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
const { dayInfo } = this.data;
|
||||||
|
return {
|
||||||
|
title: dayInfo.lunarMonthName
|
||||||
|
? `${dayInfo.lunarMonthName}${dayInfo.lunarDayName} 黄历详情`
|
||||||
|
: '每日黄历详情'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,5 +60,18 @@ Page({
|
|||||||
const { boneYearIndex, boneMonthIndex, boneDayIndex, boneHourIndex } = this.data;
|
const { boneYearIndex, boneMonthIndex, boneDayIndex, boneHourIndex } = this.data;
|
||||||
const result = calculateBoneWeight(boneYearIndex, boneMonthIndex + 1, boneDayIndex + 1, boneHourIndex);
|
const result = calculateBoneWeight(boneYearIndex, boneMonthIndex + 1, boneDayIndex + 1, boneHourIndex);
|
||||||
this.setData({ boneResult: result });
|
this.setData({ boneResult: result });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '占卜工具 - 梅花易数称骨算命',
|
||||||
|
path: '/pages/divination/divination'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '占卜工具 - 梅花易数称骨算命'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,5 +43,18 @@ Page({
|
|||||||
|
|
||||||
goBazi() {
|
goBazi() {
|
||||||
wx.switchTab({ url: '/pages/bazi/bazi' });
|
wx.switchTab({ url: '/pages/bazi/bazi' });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '每日运势 - 今日运势查询',
|
||||||
|
path: '/pages/fortune/fortune'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '每日运势 - 今日运势查询'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+193
-30
@@ -1,14 +1,24 @@
|
|||||||
const {
|
const {
|
||||||
getDayInfo, getAlmanacInfo, getMonthCalendar,
|
getDayInfo, getAlmanacInfo, getMonthCalendar,
|
||||||
getLegalHoliday, isWorkday, getUpcomingFestivals, getNextBuddhistFestival
|
getLegalHoliday, isWorkday, getUpcomingFestivals, getNextBuddhistFestival, getYearFestivals
|
||||||
} = require('../../utils/core/index.js');
|
} = require('../../utils/core/index.js');
|
||||||
const { SOLAR_TERM_TIMES } = require('../../utils/core/data.js');
|
const { SOLAR_TERM_TIMES } = require('../../utils/core/data.js');
|
||||||
|
const { request } = require('../../utils/request.js');
|
||||||
|
|
||||||
function pad(n) { return String(n).padStart(2, '0'); }
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
function fmt(y, m, d) { return `${y}-${pad(m)}-${pad(d)}`; }
|
function fmt(y, m, d) { return `${y}-${pad(m)}-${pad(d)}`; }
|
||||||
|
|
||||||
|
/** 历史上的今天年份显示:负数为公元前,0 为年份不详 */
|
||||||
|
function fmtYear(year) {
|
||||||
|
if (!year) return '';
|
||||||
|
return year < 0 ? `前${-year}年` : `${year}年`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OTD_CACHE_KEY = 'otd-cache';
|
||||||
|
const OTD_CACHE_TTL = 48 * 3600 * 1000; // 48 小时(数据按月日维度存储,变化频率低)
|
||||||
|
|
||||||
const MONTH_SPAN = 2; // 当前月前后各2个月,共5个月
|
const MONTH_SPAN = 2; // 当前月前后各2个月,共5个月
|
||||||
const DAY_SPAN = 2; // 当前日前后各2天,共5天
|
const DAY_SPAN = 3; // 当前日前后各3天,共7天(固定窗口,避免动态增删导致动画丢失)
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
@@ -26,11 +36,18 @@ Page({
|
|||||||
weekHeaders: ['日', '一', '二', '三', '四', '五', '六'],
|
weekHeaders: ['日', '一', '二', '三', '四', '五', '六'],
|
||||||
// 当前选中日期(用于月历联动)
|
// 当前选中日期(用于月历联动)
|
||||||
dayInfo: {},
|
dayInfo: {},
|
||||||
|
// 历史上的今天加载中
|
||||||
|
otdLoading: false,
|
||||||
|
// 标记是否正在程序触发 swiper 切换(避免 onDaySwiperChange 重复处理)
|
||||||
|
swiperAnimating: false,
|
||||||
|
// 标记是否需要在动画完成后滚动窗口
|
||||||
|
pendingShift: 0,
|
||||||
// 全屏弹窗
|
// 全屏弹窗
|
||||||
zoomVisible: false,
|
zoomVisible: false,
|
||||||
zoomTitle: '',
|
zoomTitle: '',
|
||||||
zoomType: '',
|
zoomType: '',
|
||||||
zoomData: null,
|
zoomData: null,
|
||||||
|
zoomScrollTop: 0,
|
||||||
// 设置
|
// 设置
|
||||||
showBuddhist: false,
|
showBuddhist: false,
|
||||||
highlightLunar: true
|
highlightLunar: true
|
||||||
@@ -81,16 +98,19 @@ Page({
|
|||||||
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
|
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
|
||||||
const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
|
const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
|
||||||
const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null;
|
const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null;
|
||||||
const upcoming = getUpcomingFestivals(5, y, m, d);
|
// 外页只显示最近3条(佛历开启时并入排序);内页弹窗见 openZoom 取10条
|
||||||
|
const upcoming = getUpcomingFestivals(3, y, m, d, this.data.showBuddhist);
|
||||||
return {
|
return {
|
||||||
key: fmt(y, m, d),
|
key: fmt(y, m, d),
|
||||||
dayInfo, almanac, legalHoliday, workday, termTime,
|
dayInfo, almanac, legalHoliday, workday, termTime,
|
||||||
isFirstOrFifteen, nextBuddhist, upcoming,
|
isFirstOrFifteen, nextBuddhist, upcoming,
|
||||||
showBuddhist: this.data.showBuddhist
|
showBuddhist: this.data.showBuddhist,
|
||||||
|
otd: null, // 历史上的今天数据(异步加载)
|
||||||
|
otdTab: 'event' // event:大事记 birth:出生
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 构建5天的数据,居中显示 */
|
/** 构建7天的数据,居中显示 */
|
||||||
buildDayList(centerDate) {
|
buildDayList(centerDate) {
|
||||||
const dayList = [];
|
const dayList = [];
|
||||||
for (let i = -DAY_SPAN; i <= DAY_SPAN; i++) {
|
for (let i = -DAY_SPAN; i <= DAY_SPAN; i++) {
|
||||||
@@ -105,6 +125,7 @@ Page({
|
|||||||
currentYear: center.dayInfo.solarYear,
|
currentYear: center.dayInfo.solarYear,
|
||||||
currentMonth: center.dayInfo.solarMonth
|
currentMonth: center.dayInfo.solarMonth
|
||||||
});
|
});
|
||||||
|
this.ensureOtd(DAY_SPAN);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置变化后刷新 dayList */
|
/** 设置变化后刷新 dayList */
|
||||||
@@ -121,7 +142,7 @@ Page({
|
|||||||
/** 日视图 swiper 切换 */
|
/** 日视图 swiper 切换 */
|
||||||
onDaySwiperChange(e) {
|
onDaySwiperChange(e) {
|
||||||
const index = e.detail.current;
|
const index = e.detail.current;
|
||||||
const { dayList } = this.data;
|
const { dayList, swiperAnimating } = this.data;
|
||||||
const cur = dayList[index];
|
const cur = dayList[index];
|
||||||
this.setData({
|
this.setData({
|
||||||
dayIndex: index,
|
dayIndex: index,
|
||||||
@@ -129,44 +150,164 @@ Page({
|
|||||||
currentYear: cur.dayInfo.solarYear,
|
currentYear: cur.dayInfo.solarYear,
|
||||||
currentMonth: cur.dayInfo.solarMonth
|
currentMonth: cur.dayInfo.solarMonth
|
||||||
});
|
});
|
||||||
|
this.ensureOtd(index);
|
||||||
|
|
||||||
// 边缘时向对应方向补一天,保持可无限滑动
|
// 程序触发切换时不处理边缘滚动(避免打断动画)
|
||||||
|
if (swiperAnimating) {
|
||||||
|
this.setData({ swiperAnimating: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户手动滑动到边缘时,标记待滚动方向,等动画完成后再滚动
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
this.prependDay();
|
this.setData({ pendingShift: -1 });
|
||||||
} else if (index === dayList.length - 1) {
|
} else if (index === dayList.length - 1) {
|
||||||
this.appendDay();
|
this.setData({ pendingShift: 1 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
prependDay() {
|
/** 日视图 swiper 动画完成 */
|
||||||
const { dayList } = this.data;
|
onDaySwiperFinish() {
|
||||||
const first = dayList[0];
|
const { pendingShift } = this.data;
|
||||||
const date = new Date(first.dayInfo.solarYear, first.dayInfo.solarMonth - 1, first.dayInfo.solarDay - 1);
|
if (pendingShift !== 0) {
|
||||||
dayList.unshift(this.buildDayData(date));
|
this.setData({ pendingShift: 0 });
|
||||||
if (dayList.length > 7) dayList.pop();
|
this.shiftDayWindow(pendingShift);
|
||||||
this.setData({ dayList, dayIndex: this.data.dayIndex + 1 });
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
appendDay() {
|
/** 滚动日数据窗口:direction -1=向前,1=向后 */
|
||||||
|
shiftDayWindow(direction) {
|
||||||
const { dayList } = this.data;
|
const { dayList } = this.data;
|
||||||
const last = dayList[dayList.length - 1];
|
const edge = direction < 0 ? dayList[0] : dayList[dayList.length - 1];
|
||||||
const date = new Date(last.dayInfo.solarYear, last.dayInfo.solarMonth - 1, last.dayInfo.solarDay + 1);
|
const offset = direction < 0 ? -1 : 1;
|
||||||
dayList.push(this.buildDayData(date));
|
const newDate = new Date(edge.dayInfo.solarYear, edge.dayInfo.solarMonth - 1, edge.dayInfo.solarDay + offset);
|
||||||
if (dayList.length > 7) dayList.shift();
|
const newDay = this.buildDayData(newDate);
|
||||||
this.setData({ dayList, dayIndex: this.data.dayIndex - 1 });
|
|
||||||
|
let newList, newIndex;
|
||||||
|
if (direction < 0) {
|
||||||
|
// 向前滚动:新数据加到前面,当前索引+1(因为前面多了一天)
|
||||||
|
newList = [newDay, ...dayList.slice(0, -1)];
|
||||||
|
newIndex = this.data.dayIndex + 1;
|
||||||
|
} else {
|
||||||
|
// 向后滚动:新数据加到后面,当前索引-1(因为前面少了一天)
|
||||||
|
newList = [...dayList.slice(1), newDay];
|
||||||
|
newIndex = this.data.dayIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动窗口后,current 保持连续,swiper 会平滑滑动到新位置
|
||||||
|
this.setData({
|
||||||
|
dayList: newList,
|
||||||
|
dayIndex: newIndex,
|
||||||
|
dayInfo: newList[newIndex].dayInfo,
|
||||||
|
currentYear: newList[newIndex].dayInfo.solarYear,
|
||||||
|
currentMonth: newList[newIndex].dayInfo.solarMonth
|
||||||
|
});
|
||||||
|
this.ensureOtd(newIndex);
|
||||||
},
|
},
|
||||||
|
|
||||||
prevDay() {
|
prevDay() {
|
||||||
if (this.data.dayIndex > 0) {
|
const { dayIndex, dayList } = this.data;
|
||||||
this.setData({ dayIndex: this.data.dayIndex - 1 });
|
if (dayIndex > 0) {
|
||||||
this.updateCurrentDayInfo();
|
// 不在边缘,正常切换
|
||||||
|
const index = dayIndex - 1;
|
||||||
|
this.setData({ dayIndex: index, swiperAnimating: true });
|
||||||
|
this.ensureOtd(index);
|
||||||
|
} else {
|
||||||
|
// 在边缘,滚动窗口(窗口滚动后 dayIndex 会调整为 1,swiper 从 0 滑到 1,有动画)
|
||||||
|
this.shiftDayWindow(-1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
nextDay() {
|
nextDay() {
|
||||||
if (this.data.dayIndex < this.data.dayList.length - 1) {
|
const { dayIndex, dayList } = this.data;
|
||||||
this.setData({ dayIndex: this.data.dayIndex + 1 });
|
if (dayIndex < dayList.length - 1) {
|
||||||
this.updateCurrentDayInfo();
|
// 不在边缘,正常切换
|
||||||
|
const index = dayIndex + 1;
|
||||||
|
this.setData({ dayIndex: index, swiperAnimating: true });
|
||||||
|
this.ensureOtd(index);
|
||||||
|
} else {
|
||||||
|
// 在边缘,滚动窗口(窗口滚动后 dayIndex 会调整为 5,swiper 从 6 滑到 5,有动画)
|
||||||
|
this.shiftDayWindow(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** ===== 历史上的今天(数据来源:中文维基百科) ===== */
|
||||||
|
|
||||||
|
/** 确保指定 index 的日期已加载 OTD 数据(优先读缓存) */
|
||||||
|
ensureOtd(index) {
|
||||||
|
const item = this.data.dayList[index];
|
||||||
|
if (!item || item.otd || item.otdLoading) return;
|
||||||
|
|
||||||
|
const m = item.dayInfo.solarMonth;
|
||||||
|
const d = item.dayInfo.solarDay;
|
||||||
|
const cacheKey = `${m}-${d}`;
|
||||||
|
|
||||||
|
// 读本地缓存(按月日维度,48 小时有效)
|
||||||
|
try {
|
||||||
|
const cache = wx.getStorageSync(OTD_CACHE_KEY) || {};
|
||||||
|
const hit = cache[cacheKey];
|
||||||
|
if (hit && hit.data && Date.now() - hit.ts < OTD_CACHE_TTL) {
|
||||||
|
this.applyOtd(index, item.key, hit.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* 缓存不可用时忽略 */ }
|
||||||
|
|
||||||
|
this.setData({ [`dayList[${index}].otdLoading`]: true });
|
||||||
|
request({ url: `/api/wiki/on-this-day?month=${m}&day=${d}` })
|
||||||
|
.then(res => {
|
||||||
|
if (res && res.code === 0 && res.data) {
|
||||||
|
this.applyOtd(index, item.key, res.data);
|
||||||
|
this.saveOtdCache(cacheKey, res.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* 网络失败时不显示卡片 */ })
|
||||||
|
.then(() => {
|
||||||
|
// 窗口可能已滚动,先确认 index 处仍是同一天
|
||||||
|
const cur = this.data.dayList[index];
|
||||||
|
if (cur && cur.key === item.key) {
|
||||||
|
this.setData({ [`dayList[${index}].otdLoading`]: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 将 OTD 数据写入指定日期(异步返回时窗口可能已滚动,需校验 key) */
|
||||||
|
applyOtd(index, key, data) {
|
||||||
|
const cur = this.data.dayList[index];
|
||||||
|
if (!cur || cur.key !== key) return;
|
||||||
|
|
||||||
|
const withYear = list => (list || []).map(it => ({ ...it, yearText: fmtYear(it.year) }));
|
||||||
|
const events = withYear(data.events);
|
||||||
|
const births = withYear(data.births);
|
||||||
|
const deaths = withYear(data.deaths);
|
||||||
|
const festivals = withYear(data.festivals);
|
||||||
|
if (!events.length && !births.length) return; // 无数据不显示卡片
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
[`dayList[${index}].otd`]: {
|
||||||
|
events, births, deaths, festivals,
|
||||||
|
eventsPreview: events.slice(0, 4),
|
||||||
|
birthsPreview: births.slice(0, 4)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 写入本地缓存(最多保留 80 天,超出淘汰最早的) */
|
||||||
|
saveOtdCache(cacheKey, data) {
|
||||||
|
try {
|
||||||
|
const cache = wx.getStorageSync(OTD_CACHE_KEY) || {};
|
||||||
|
cache[cacheKey] = { ts: Date.now(), data };
|
||||||
|
const keys = Object.keys(cache);
|
||||||
|
if (keys.length > 80) delete cache[keys[0]];
|
||||||
|
wx.setStorageSync(OTD_CACHE_KEY, cache);
|
||||||
|
} catch (e) { /* 存储失败时忽略 */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 卡片内切换 大事记/出生 */
|
||||||
|
switchOtdTab(e) {
|
||||||
|
const { date, tab } = e.currentTarget.dataset;
|
||||||
|
const index = this.data.dayList.findIndex(item => item.key === date);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.setData({ [`dayList[${index}].otdTab`]: tab });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -290,7 +431,14 @@ Page({
|
|||||||
};
|
};
|
||||||
} else if (type === 'fest') {
|
} else if (type === 'fest') {
|
||||||
zoomTitle = '近期节日';
|
zoomTitle = '近期节日';
|
||||||
zoomData = { upcoming: dayItem.upcoming };
|
// 内页显示最近10条(佛历开启时并入排序)
|
||||||
|
zoomData = { upcoming: getUpcomingFestivals(10, dayInfo.solarYear, dayInfo.solarMonth, dayInfo.solarDay, this.data.showBuddhist) };
|
||||||
|
} else if (type === 'fest-year') {
|
||||||
|
zoomTitle = `${dayInfo.solarYear}年节日`;
|
||||||
|
zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) };
|
||||||
|
} else if (type === 'otd') {
|
||||||
|
zoomTitle = `${dayInfo.solarMonth}月${dayInfo.solarDay}日 历史上的今天`;
|
||||||
|
zoomData = dayItem.otd;
|
||||||
}
|
}
|
||||||
this.setData({ zoomVisible: true, zoomTitle, zoomType: type, zoomData });
|
this.setData({ zoomVisible: true, zoomTitle, zoomType: type, zoomData });
|
||||||
},
|
},
|
||||||
@@ -303,5 +451,20 @@ Page({
|
|||||||
wx.navigateTo({ url: e.currentTarget.dataset.url });
|
wx.navigateTo({ url: e.currentTarget.dataset.url });
|
||||||
},
|
},
|
||||||
|
|
||||||
noop() {}
|
noop() {},
|
||||||
|
|
||||||
|
// 分享给好友
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '老黄历 - 传统农历黄历查询',
|
||||||
|
path: '/pages/home/home'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 分享到朋友圈
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '老黄历 - 传统农历黄历查询'
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+97
-24
@@ -1,13 +1,14 @@
|
|||||||
<!-- 自定义导航栏 -->
|
<!-- 自定义导航栏 -->
|
||||||
<view class="custom-nav" style="padding-top: {{statusBarHeight}}px;">
|
<view class="custom-nav" style="padding-top: {{statusBarHeight}}px;">
|
||||||
<view class="nav-inner" style="height: {{navBarHeight}}px;">
|
<view class="nav-inner" style="height: {{navBarHeight}}px;">
|
||||||
|
<view class="nav-left">
|
||||||
<view class="nav-toggle" bindtap="toggleView">
|
<view class="nav-toggle" bindtap="toggleView">
|
||||||
<text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text>
|
<text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="nav-title font-chinese">老黄历</text>
|
|
||||||
<view class="nav-right">
|
|
||||||
<text class="nav-zodiac">{{dayInfo.zodiac}}年</text>
|
<text class="nav-zodiac">{{dayInfo.zodiac}}年</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="nav-title font-chinese">老黄历</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
class="day-swiper"
|
class="day-swiper"
|
||||||
current="{{dayIndex}}"
|
current="{{dayIndex}}"
|
||||||
bindchange="onDaySwiperChange"
|
bindchange="onDaySwiperChange"
|
||||||
|
bindanimationfinish="onDaySwiperFinish"
|
||||||
duration="300"
|
duration="300"
|
||||||
circular="{{false}}"
|
circular="{{false}}"
|
||||||
>
|
>
|
||||||
@@ -64,8 +66,8 @@
|
|||||||
星期{{dayItem.dayInfo.weekDay}} · {{dayItem.legalHoliday ? dayItem.legalHoliday + ' · 放假' : (dayItem.workday ? '工作日 · 上班' : '周末 · 休息')}}
|
星期{{dayItem.dayInfo.weekDay}} · {{dayItem.legalHoliday ? dayItem.legalHoliday + ' · 放假' : (dayItem.workday ? '工作日 · 上班' : '周末 · 休息')}}
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 宜忌(在大框里面) -->
|
<!-- 宜忌(在大框里面,点击放大) -->
|
||||||
<view class="day-frame-yiji">
|
<view class="day-frame-yiji" bindtap="openZoom" data-type="yiji" data-date="{{dayItem.key}}">
|
||||||
<view class="frame-yiji-row">
|
<view class="frame-yiji-row">
|
||||||
<view class="frame-yiji-title yi">宜</view>
|
<view class="frame-yiji-title yi">宜</view>
|
||||||
<view class="frame-yiji-items">
|
<view class="frame-yiji-items">
|
||||||
@@ -91,46 +93,75 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 黄历详情 -->
|
<!-- 黄历详情(紧凑布局,每行2个) -->
|
||||||
<view class="card" bindtap="openZoom" data-type="detail" data-date="{{dayItem.key}}">
|
<view class="card" bindtap="openZoom" data-type="detail" data-date="{{dayItem.key}}">
|
||||||
<view class="detail-grid">
|
<view class="detail-grid-compact">
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">值神</text>
|
<text class="detail-label">值神</text>
|
||||||
<text class="detail-value">{{dayItem.almanac.duty}}</text>
|
<text class="detail-value">{{dayItem.almanac.duty}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">黄道</text>
|
<text class="detail-label">黄道</text>
|
||||||
<text class="detail-value">{{dayItem.almanac.twelveStar.name}}({{dayItem.almanac.twelveStar.ecliptic}})</text>
|
<text class="detail-value">{{dayItem.almanac.twelveStar.name}}({{dayItem.almanac.twelveStar.ecliptic}})</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">二十八宿</text>
|
<text class="detail-label">二十八宿</text>
|
||||||
<text class="detail-value">{{dayItem.almanac.twentyEightStar.name}}</text>
|
<text class="detail-value">{{dayItem.almanac.twentyEightStar.name}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">六曜</text>
|
<text class="detail-label">六曜</text>
|
||||||
<text class="detail-value">{{dayItem.almanac.sixStar}}</text>
|
<text class="detail-value">{{dayItem.almanac.sixStar}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">冲煞</text>
|
<text class="detail-label">冲煞</text>
|
||||||
<text class="detail-value">冲{{dayItem.almanac.clash}} 煞{{dayItem.almanac.evilDirection}}</text>
|
<text class="detail-value">冲{{dayItem.almanac.clash}} 煞{{dayItem.almanac.evilDirection}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-item">
|
<view class="detail-item-compact">
|
||||||
<text class="detail-label">彭祖百忌</text>
|
<text class="detail-label">彭祖百忌</text>
|
||||||
<text class="detail-value">{{dayItem.almanac.pengZu}}</text>
|
<text class="detail-value">{{dayItem.almanac.pengZu}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 节日速查 -->
|
<!-- 节日速查(显示最近10条,点击查看更多) -->
|
||||||
<view class="card" bindtap="openZoom" data-type="fest" data-date="{{dayItem.key}}">
|
<view class="card">
|
||||||
<view class="section-title">节日速查</view>
|
<view class="fest-head">
|
||||||
<view class="fest-quick">
|
<text class="section-title">节日速查</text>
|
||||||
|
<text class="fest-more" bindtap="openZoom" data-type="fest-year" data-date="{{dayItem.key}}">更多 >></text>
|
||||||
|
</view>
|
||||||
|
<view class="fest-quick" wx:if="{{dayItem.upcoming && dayItem.upcoming.length > 0}}">
|
||||||
<view class="fest-quick-item" wx:for="{{dayItem.upcoming}}" wx:key="name" catchtap="goFestival" data-date="{{item.solarDate}}">
|
<view class="fest-quick-item" wx:for="{{dayItem.upcoming}}" wx:key="name" catchtap="goFestival" data-date="{{item.solarDate}}">
|
||||||
<text class="fest-name">{{item.name}}</text>
|
<text class="fest-name">{{item.name}}</text>
|
||||||
<text class="fest-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
|
<text class="fest-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
|
||||||
<text class="fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : item.daysLeft + '天后'}}</text>
|
<text class="fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : item.daysLeft + '天后'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="fest-empty" wx:else>暂无近期节日</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 历史上的今天(数据来源:中文维基百科) -->
|
||||||
|
<view class="card otd-card" wx:if="{{dayItem.otd}}">
|
||||||
|
<view class="otd-head">
|
||||||
|
<text class="section-title">历史上的今天</text>
|
||||||
|
<view class="otd-tabs">
|
||||||
|
<text class="otd-tab {{dayItem.otdTab === 'event' ? 'active' : ''}}" catchtap="switchOtdTab" data-date="{{dayItem.key}}" data-tab="event">大事记</text>
|
||||||
|
<text class="otd-tab {{dayItem.otdTab === 'birth' ? 'active' : ''}}" catchtap="switchOtdTab" data-date="{{dayItem.key}}" data-tab="birth">出生</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="otd-list">
|
||||||
|
<view class="otd-item" wx:for="{{dayItem.otdTab === 'event' ? dayItem.otd.eventsPreview : dayItem.otd.birthsPreview}}" wx:key="id" wx:for-item="ev">
|
||||||
|
<text class="otd-year" wx:if="{{ev.yearText}}">{{ev.yearText}}</text>
|
||||||
|
<text class="otd-text {{ev.yearText ? '' : 'no-year'}}">{{ev.content}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="otd-foot">
|
||||||
|
<text class="otd-source">来源:中文维基百科</text>
|
||||||
|
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 >></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card placeholder-card" wx:elif="{{dayItem.otdLoading}}">
|
||||||
|
<view class="section-title">历史上的今天</view>
|
||||||
|
<view class="placeholder-content">加载中…</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 回今天 -->
|
<!-- 回今天 -->
|
||||||
@@ -161,15 +192,12 @@
|
|||||||
<swiper-item wx:for="{{monthList}}" wx:key="key">
|
<swiper-item wx:for="{{monthList}}" wx:key="key">
|
||||||
<scroll-view scroll-y class="month-scroll">
|
<scroll-view scroll-y class="month-scroll">
|
||||||
<view class="grid grid-cols-7 month-grid">
|
<view class="grid grid-cols-7 month-grid">
|
||||||
<view
|
<block wx:for="{{item.weeks}}" wx:for-item="week" wx:key="index">
|
||||||
class="day-cell {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
|
||||||
wx:for="{{item.weeks}}"
|
|
||||||
wx:for-item="week"
|
|
||||||
wx:key="week"
|
|
||||||
>
|
|
||||||
<block wx:for="{{week}}" wx:for-item="d" wx:key="solarDate">
|
|
||||||
<view
|
<view
|
||||||
class="day-cell-inner {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
class="day-cell-inner {{d.isToday ? 'today' : ''}} {{d.solarMonth !== item.month ? 'other-month' : ''}}"
|
||||||
|
wx:for="{{week}}"
|
||||||
|
wx:for-item="d"
|
||||||
|
wx:key="solarDate"
|
||||||
bindtap="selectDay"
|
bindtap="selectDay"
|
||||||
data-date="{{d.solarDate}}"
|
data-date="{{d.solarDate}}"
|
||||||
>
|
>
|
||||||
@@ -181,7 +209,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
@@ -209,6 +236,7 @@
|
|||||||
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
|
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="zoom-end-pad" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 详情放大 -->
|
<!-- 详情放大 -->
|
||||||
@@ -253,6 +281,7 @@
|
|||||||
<text class="zoom-detail-label">彭祖百忌</text>
|
<text class="zoom-detail-label">彭祖百忌</text>
|
||||||
<text class="zoom-detail-value">{{zoomData.pengZu}}</text>
|
<text class="zoom-detail-value">{{zoomData.pengZu}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="zoom-end-pad" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 节日放大 -->
|
<!-- 节日放大 -->
|
||||||
@@ -262,6 +291,50 @@
|
|||||||
<text class="zoom-fest-date">{{item.solarDate}}</text>
|
<text class="zoom-fest-date">{{item.solarDate}}</text>
|
||||||
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
|
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="zoom-end-pad" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 今年节日(字体稍小,避免溢出) -->
|
||||||
|
<scroll-view scroll-y class="zoom-body" scroll-top="{{zoomScrollTop}}" wx:if="{{zoomType === 'fest-year' && zoomData}}">
|
||||||
|
<view class="zoom-fest-year-row" wx:for="{{zoomData.yearFestivals}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}">
|
||||||
|
<text class="zoom-fest-year-name">{{item.name}}</text>
|
||||||
|
<text class="zoom-fest-year-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
|
||||||
|
<text class="zoom-fest-year-type {{item.type === 'lunar' ? 'lunar' : 'solar'}}">{{item.type === 'lunar' ? '农历' : '公历'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="zoom-end-pad" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 历史上的今天放大 -->
|
||||||
|
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'otd' && zoomData}}">
|
||||||
|
<block wx:if="{{zoomData.events.length}}">
|
||||||
|
<view class="zoom-otd-title">大事记</view>
|
||||||
|
<view class="zoom-otd-item" wx:for="{{zoomData.events}}" wx:key="id">
|
||||||
|
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||||
|
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<block wx:if="{{zoomData.births.length}}">
|
||||||
|
<view class="zoom-otd-title">出生</view>
|
||||||
|
<view class="zoom-otd-item" wx:for="{{zoomData.births}}" wx:key="id">
|
||||||
|
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||||
|
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<block wx:if="{{zoomData.deaths.length}}">
|
||||||
|
<view class="zoom-otd-title">逝世</view>
|
||||||
|
<view class="zoom-otd-item" wx:for="{{zoomData.deaths}}" wx:key="id">
|
||||||
|
<text class="otd-year" wx:if="{{item.yearText}}">{{item.yearText}}</text>
|
||||||
|
<text class="otd-text {{item.yearText ? '' : 'no-year'}}">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<block wx:if="{{zoomData.festivals.length}}">
|
||||||
|
<view class="zoom-otd-title">节假日与习俗</view>
|
||||||
|
<view class="zoom-otd-item" wx:for="{{zoomData.festivals}}" wx:key="id">
|
||||||
|
<text class="otd-text no-year">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0)</view>
|
||||||
|
<view class="zoom-end-pad" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
+294
-32
@@ -13,6 +13,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle {
|
.nav-toggle {
|
||||||
@@ -36,6 +43,13 @@
|
|||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 4rpx;
|
letter-spacing: 4rpx;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
min-width: 96rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-right {
|
.nav-right {
|
||||||
@@ -46,8 +60,9 @@
|
|||||||
|
|
||||||
.nav-zodiac {
|
.nav-zodiac {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-body {
|
.page-body {
|
||||||
@@ -176,11 +191,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
padding: 0 16rpx;
|
padding: 0 8rpx; /* 减小padding让数字有更多空间 */
|
||||||
|
min-width: 0; /* 允许内容收缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.almanac-day-num {
|
.almanac-day-num {
|
||||||
font-size: 200rpx;
|
font-size: 240rpx; /* 宜忌固定高度后,日历数字可以放大 */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -284,14 +300,35 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8rpx;
|
gap: 12rpx 8rpx; /* 行间距12rpx,列间距8rpx */
|
||||||
align-content: flex-start;
|
align-content: flex-start;
|
||||||
|
max-height: 88rpx; /* 固定2行高度:item 32rpx*2 + 行距12rpx + padding 12rpx */
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-yiji-items::after {
|
||||||
|
content: '...';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #FFFDF8;
|
||||||
|
padding-left: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
line-height: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frame-yiji-item {
|
.frame-yiji-item {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
padding: 4rpx 12rpx;
|
padding: 4rpx 12rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
line-height: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.frame-yiji-item.yi { background: #FEF2F2; color: #C41E3A; }
|
.frame-yiji-item.yi { background: #FEF2F2; color: #C41E3A; }
|
||||||
@@ -315,37 +352,118 @@
|
|||||||
color: #C41E3A;
|
color: #C41E3A;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 黄历详情(label 大、value 小、加间隔) */
|
/* 黄历详情(紧凑布局,每行2个) */
|
||||||
.detail-grid {
|
.detail-grid-compact {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 20rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 12rpx;
|
||||||
padding: 16rpx;
|
padding: 12rpx;
|
||||||
background: #FFFBF5;
|
background: #FFFBF5;
|
||||||
border-radius: 12rpx;
|
border-radius: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-item-compact .detail-label {
|
||||||
font-size: 28rpx;
|
font-size: 24rpx;
|
||||||
color: #9E8E7E;
|
color: #9E8E7E;
|
||||||
min-width: 140rpx;
|
min-width: 70rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-item-compact .detail-value {
|
||||||
font-size: 28rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 节日速查 */
|
/* 预留空间占位卡片 */
|
||||||
|
.placeholder-card {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #C9B8A6;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节日速查头部 */
|
||||||
|
.fest-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fest-head .section-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fest-more {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fest-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #C9B8A6;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 今年节日弹窗(字体稍小) */
|
||||||
|
.zoom-fest-year-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #F0E6DA;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fest-year-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 160rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fest-year-date {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fest-year-type {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fest-year-type.lunar {
|
||||||
|
background: #FEF2F2;
|
||||||
|
color: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fest-year-type.solar {
|
||||||
|
background: #F0F9FF;
|
||||||
|
color: #0369A1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节日速查列表 */
|
||||||
.fest-quick {
|
.fest-quick {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -377,6 +495,9 @@
|
|||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #C41E3A;
|
color: #C41E3A;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 100rpx;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fest-left.is-today {
|
.fest-left.is-today {
|
||||||
@@ -453,10 +574,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16rpx 4rpx;
|
padding: 8rpx 4rpx;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
min-height: 110rpx;
|
min-height: 80rpx; /* 压缩格子高度,避免数字看起来竖向排列 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-cell-inner.today {
|
.day-cell-inner.today {
|
||||||
@@ -473,14 +594,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.day-number {
|
.day-number {
|
||||||
font-size: 30rpx;
|
font-size: 36rpx; /* 月历数字放大 */
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-lunar {
|
.day-lunar {
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
color: #9E8E7E;
|
color: #9E8E7E;
|
||||||
margin-top: 4rpx;
|
margin-top: 2rpx;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-lunar.festival {
|
.day-lunar.festival {
|
||||||
@@ -549,10 +672,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.zoom-body {
|
.zoom-body {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 32rpx 28rpx;
|
padding: 32rpx 28rpx;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scroll-view 底部占位:padding-bottom 不计入滚动高度,用真实元素保证最后一项完整显示 */
|
||||||
|
.zoom-end-pad {
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 宜忌放大 */
|
/* 宜忌放大 */
|
||||||
.zoom-yiji-block {
|
.zoom-yiji-block {
|
||||||
margin-bottom: 40rpx;
|
margin-bottom: 40rpx;
|
||||||
@@ -576,13 +707,13 @@
|
|||||||
.zoom-yiji-items {
|
.zoom-yiji-items {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 20rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-yiji-item {
|
.zoom-yiji-item {
|
||||||
font-size: 44rpx;
|
font-size: 32rpx;
|
||||||
padding: 16rpx 32rpx;
|
padding: 12rpx 24rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 12rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,21 +756,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.zoom-fest-name {
|
.zoom-fest-name {
|
||||||
font-size: 44rpx;
|
font-size: 40rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 200rpx;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-fest-date {
|
.zoom-fest-date {
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
color: #9E8E7E;
|
color: #9E8E7E;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-fest-left {
|
.zoom-fest-left {
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
color: #C41E3A;
|
color: #C41E3A;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-fest-left.is-today {
|
.zoom-fest-left.is-today {
|
||||||
@@ -648,3 +782,131 @@
|
|||||||
padding: 4rpx 24rpx;
|
padding: 4rpx 24rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 历史上的今天 ===== */
|
||||||
|
.otd-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-head .section-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-tab {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 4rpx 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
|
background: #F5EFE8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-tab.active {
|
||||||
|
color: #FFFFFF;
|
||||||
|
background: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-year {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 104rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #5A4E42;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-text.no-year {
|
||||||
|
margin-left: 116rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #F0E8DE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-source {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #C9B8A6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otd-more {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 历史上的今天放大弹窗 ===== */
|
||||||
|
.zoom-otd-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #C41E3A;
|
||||||
|
margin: 24rpx 0 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
border-bottom: 1rpx solid #F0E8DE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-item .otd-year {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-item .otd-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-item .otd-text.no-year {
|
||||||
|
margin-left: 136rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-otd-source {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #C9B8A6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
weekStartDay: 0,
|
||||||
|
ziHourSect: 'lateZiNextDay',
|
||||||
|
solarTime: true,
|
||||||
|
showBuddhist: false,
|
||||||
|
highlightLunar: true
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.loadSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSettings() {
|
||||||
|
const settings = wx.getStorageSync('lunar-settings') || {};
|
||||||
|
this.setData({
|
||||||
|
weekStartDay: settings.weekStartDay || 0,
|
||||||
|
ziHourSect: settings.ziHourSect || 'lateZiNextDay',
|
||||||
|
solarTime: settings.solarTime !== false,
|
||||||
|
showBuddhist: settings.showBuddhist === true,
|
||||||
|
highlightLunar: settings.highlightLunar !== false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSettings() {
|
||||||
|
const { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar } = this.data;
|
||||||
|
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar });
|
||||||
|
},
|
||||||
|
|
||||||
|
setWeekStart(e) {
|
||||||
|
this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) });
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
setZiSect(e) {
|
||||||
|
this.setData({ ziHourSect: e.currentTarget.dataset.v });
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
onSolarTimeChange(e) {
|
||||||
|
this.setData({ solarTime: e.detail.value });
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
onBuddhistChange(e) {
|
||||||
|
this.setData({ showBuddhist: e.detail.value });
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
onHighlightLunarChange(e) {
|
||||||
|
this.setData({ highlightLunar: e.detail.value });
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '偏好设置 - 老黄历',
|
||||||
|
path: '/pages/preferences/preferences'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '偏好设置 - 老黄历'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "偏好设置",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="card">
|
||||||
|
<view class="section-title">偏好设置</view>
|
||||||
|
|
||||||
|
<view class="setting-item">
|
||||||
|
<text class="setting-label">周起始日</text>
|
||||||
|
<view class="flex gap-2">
|
||||||
|
<view class="btn-outline {{weekStartDay === 0 ? 'active' : ''}}" bindtap="setWeekStart" data-v="0">周日</view>
|
||||||
|
<view class="btn-outline {{weekStartDay === 1 ? 'active' : ''}}" bindtap="setWeekStart" data-v="1">周一</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-item">
|
||||||
|
<text class="setting-label">子时流派</text>
|
||||||
|
<view class="flex gap-2">
|
||||||
|
<view class="btn-outline {{ziHourSect === 'lateZiNextDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiNextDay">晚子时算次日</view>
|
||||||
|
<view class="btn-outline {{ziHourSect === 'lateZiSameDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiSameDay">晚子时算当日</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-item">
|
||||||
|
<text class="setting-label">真太阳时校正</text>
|
||||||
|
<switch checked="{{solarTime}}" bindchange="onSolarTimeChange" color="#C41E3A"/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-item">
|
||||||
|
<text class="setting-label">显示佛历提醒</text>
|
||||||
|
<switch checked="{{showBuddhist}}" bindchange="onBuddhistChange" color="#C41E3A"/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-item">
|
||||||
|
<text class="setting-label">初一十五高亮</text>
|
||||||
|
<switch checked="{{highlightLunar}}" bindchange="onHighlightLunarChange" color="#C41E3A"/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="text-xs text-muted text-center mt-2">设置仅保存在本机,修改后立即生效</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.container {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
color: #C41E3A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #E8D5C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline.active {
|
||||||
|
background: #C41E3A;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-color: #C41E3A;
|
||||||
|
}
|
||||||
@@ -1,98 +1,117 @@
|
|||||||
const versionInfo = require('../../utils/version.js');
|
const versionInfo = require('../../utils/version.js');
|
||||||
|
const { request } = require('../../utils/request.js');
|
||||||
|
const birthProfile = require('../../utils/birth-profile.js');
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
userInfo: null,
|
userInfo: null,
|
||||||
weekStartDay: 0,
|
loggedIn: false,
|
||||||
ziHourSect: 'lateZiNextDay',
|
logging: false,
|
||||||
solarTime: true,
|
// 菜单角标
|
||||||
showBuddhist: false,
|
profileCount: 0,
|
||||||
highlightLunar: true,
|
maxFree: birthProfile.MAX_PROFILES,
|
||||||
bookmarks: [],
|
bookmarkCount: 0,
|
||||||
version: versionInfo.version,
|
version: versionInfo.version,
|
||||||
buildNumber: versionInfo.buildNumber,
|
buildNumber: versionInfo.buildNumber,
|
||||||
buildTime: versionInfo.buildTime,
|
buildTime: versionInfo.buildTime,
|
||||||
commitSha: versionInfo.commitSha
|
commitSha: versionInfo.commitSha
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
|
||||||
this.loadSettings();
|
|
||||||
this.loadBookmarks();
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
this.loadBookmarks();
|
this.refreshUser();
|
||||||
|
this.refreshCounts();
|
||||||
},
|
},
|
||||||
|
|
||||||
loadSettings() {
|
refreshUser() {
|
||||||
const settings = wx.getStorageSync('lunar-settings') || {};
|
const token = wx.getStorageSync('token');
|
||||||
|
const userInfo = wx.getStorageSync('lunar-user-info');
|
||||||
|
this.setData({ loggedIn: !!token, userInfo: userInfo || null });
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshCounts() {
|
||||||
|
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
||||||
|
const profiles = birthProfile.getProfiles();
|
||||||
this.setData({
|
this.setData({
|
||||||
weekStartDay: settings.weekStartDay || 0,
|
bookmarkCount: bookmarks.length,
|
||||||
ziHourSect: settings.ziHourSect || 'lateZiNextDay',
|
profileCount: profiles.length
|
||||||
solarTime: settings.solarTime !== false,
|
|
||||||
showBuddhist: settings.showBuddhist === true,
|
|
||||||
highlightLunar: settings.highlightLunar !== false
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
saveSettings() {
|
// ==================== 登录 ====================
|
||||||
const { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar } = this.data;
|
|
||||||
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar });
|
login() {
|
||||||
|
if (this.data.logging) return;
|
||||||
|
this.setData({ logging: true });
|
||||||
|
wx.login({
|
||||||
|
success: (loginRes) => {
|
||||||
|
if (!loginRes.code) {
|
||||||
|
this.setData({ logging: false });
|
||||||
|
wx.showToast({ title: '授权登录失败', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request({
|
||||||
|
url: '/api/user/login',
|
||||||
|
method: 'POST',
|
||||||
|
data: { code: loginRes.code }
|
||||||
|
}).then((res) => {
|
||||||
|
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 });
|
||||||
|
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||||
|
// 登录后拉取云端生辰档案,保证菜单计数准确
|
||||||
|
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
|
||||||
|
} else {
|
||||||
|
this.setData({ logging: false });
|
||||||
|
wx.showToast({ title: (res && res.msg) || '登录失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
this.setData({ logging: false });
|
||||||
|
wx.showToast({ title: '登录失败,请稍后重试', icon: 'none' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.setData({ logging: false });
|
||||||
|
wx.showToast({ title: '授权登录失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadBookmarks() {
|
logout() {
|
||||||
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
wx.showModal({
|
||||||
this.setData({ bookmarks });
|
title: '退出登录',
|
||||||
},
|
content: '退出后生辰档案仅保存在本机,重新登录可恢复云端档案',
|
||||||
|
success: (res) => {
|
||||||
setWeekStart(e) {
|
if (!res.confirm) return;
|
||||||
this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) });
|
wx.removeStorageSync('token');
|
||||||
this.saveSettings();
|
wx.removeStorageSync('lunar-user-info');
|
||||||
},
|
this.setData({ userInfo: null, loggedIn: false });
|
||||||
|
wx.showToast({ title: '已退出', icon: 'success' });
|
||||||
setZiSect(e) {
|
}
|
||||||
this.setData({ ziHourSect: e.currentTarget.dataset.v });
|
});
|
||||||
this.saveSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
onSolarTimeChange(e) {
|
|
||||||
this.setData({ solarTime: e.detail.value });
|
|
||||||
this.saveSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
onBuddhistChange(e) {
|
|
||||||
this.setData({ showBuddhist: e.detail.value });
|
|
||||||
this.saveSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
onHighlightLunarChange(e) {
|
|
||||||
this.setData({ highlightLunar: e.detail.value });
|
|
||||||
this.saveSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
removeBookmark(e) {
|
|
||||||
const date = e.currentTarget.dataset.date;
|
|
||||||
let bookmarks = this.data.bookmarks.filter(b => b.date !== date);
|
|
||||||
wx.setStorageSync('lunar-bookmarks', bookmarks);
|
|
||||||
this.setData({ bookmarks });
|
|
||||||
wx.showToast({ title: '已删除', icon: 'success' });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
navTo(e) {
|
navTo(e) {
|
||||||
wx.navigateTo({ url: e.currentTarget.dataset.url });
|
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'];
|
||||||
|
if (tabPages.includes(url)) {
|
||||||
|
wx.switchTab({ url });
|
||||||
|
} else {
|
||||||
|
wx.navigateTo({ url });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
login() {
|
onShareAppMessage() {
|
||||||
wx.getUserProfile({
|
return {
|
||||||
desc: '用于完善用户资料',
|
title: '老黄历 - 个人中心',
|
||||||
success: (res) => {
|
path: '/pages/settings/settings'
|
||||||
this.setData({ userInfo: res.userInfo });
|
};
|
||||||
wx.setStorageSync('lunar-user-info', res.userInfo);
|
|
||||||
wx.showToast({ title: '登录成功', icon: 'success' });
|
|
||||||
},
|
},
|
||||||
fail: () => {
|
|
||||||
wx.showToast({ title: '登录失败', icon: 'none' });
|
onShareTimeline() {
|
||||||
}
|
return {
|
||||||
});
|
title: '老黄历 - 个人中心'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,73 +1,57 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 用户信息 -->
|
<!-- 登录与账号 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">用户信息</view>
|
<view class="flex items-center justify-between" wx:if="{{loggedIn}}">
|
||||||
<view class="flex items-center gap-2" wx:if="{{userInfo}}">
|
<view class="flex items-center gap-2">
|
||||||
<image src="{{userInfo.avatarUrl}}" class="avatar" mode="aspectFill"/>
|
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
|
||||||
|
<view class="avatar avatar-placeholder" wx:else>
|
||||||
|
<text>客</text>
|
||||||
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<text class="text-base font-medium">{{userInfo.nickName}}</text>
|
<text class="text-sm font-medium">{{userInfo.nickname || '授权用户'}}</text>
|
||||||
<text class="text-xs text-muted block">已登录</text>
|
<text class="text-xs text-muted block">已登录,生辰档案云端同步</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn-primary" wx:else bindtap="login">微信登录</view>
|
<view class="btn-ghost text-muted" bindtap="logout">退出</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="flex items-center justify-between" wx:else>
|
||||||
<!-- 偏好设置 -->
|
<view class="flex items-center gap-2">
|
||||||
<view class="card">
|
<view class="avatar avatar-placeholder">
|
||||||
<view class="section-title">偏好设置</view>
|
<text>客</text>
|
||||||
|
|
||||||
<view class="setting-item">
|
|
||||||
<text class="setting-label">周起始日</text>
|
|
||||||
<view class="flex gap-2">
|
|
||||||
<view class="btn-outline {{weekStartDay === 0 ? 'active' : ''}}" bindtap="setWeekStart" data-v="0">周日</view>
|
|
||||||
<view class="btn-outline {{weekStartDay === 1 ? 'active' : ''}}" bindtap="setWeekStart" data-v="1">周一</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="setting-item">
|
|
||||||
<text class="setting-label">子时流派</text>
|
|
||||||
<view class="flex gap-2">
|
|
||||||
<view class="btn-outline {{ziHourSect === 'lateZiNextDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiNextDay">晚子时算次日</view>
|
|
||||||
<view class="btn-outline {{ziHourSect === 'lateZiSameDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiSameDay">晚子时算当日</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="setting-item">
|
|
||||||
<text class="setting-label">真太阳时校正</text>
|
|
||||||
<switch checked="{{solarTime}}" bindchange="onSolarTimeChange" color="#C41E3A"/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="setting-item">
|
|
||||||
<text class="setting-label">显示佛历提醒</text>
|
|
||||||
<switch checked="{{showBuddhist}}" bindchange="onBuddhistChange" color="#C41E3A"/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="setting-item">
|
|
||||||
<text class="setting-label">初一十五高亮</text>
|
|
||||||
<switch checked="{{highlightLunar}}" bindchange="onHighlightLunarChange" color="#C41E3A"/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 收藏管理 -->
|
|
||||||
<view class="card">
|
|
||||||
<view class="section-title">我的收藏</view>
|
|
||||||
<view class="bookmark-list" wx:if="{{bookmarks.length > 0}}">
|
|
||||||
<view class="bookmark-item" wx:for="{{bookmarks}}" wx:key="date">
|
|
||||||
<view class="flex justify-between items-center">
|
|
||||||
<view>
|
<view>
|
||||||
<text class="text-base font-medium">{{item.date}}</text>
|
<text class="text-sm font-medium">未登录</text>
|
||||||
<text class="text-xs text-muted block">{{item.lunarDate}}</text>
|
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
|
||||||
</view>
|
|
||||||
<view class="btn-ghost text-unlucky" bindtap="removeBookmark" data-date="{{item.date}}">删除</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="btn-primary btn-sm" bindtap="login">{{logging ? '登录中…' : '授权登录'}}</view>
|
||||||
<view class="text-center text-muted" wx:else>
|
|
||||||
<text>暂无收藏</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 功能入口 -->
|
<!-- 我的数据 -->
|
||||||
|
<view class="card">
|
||||||
|
<view class="section-title">我的数据</view>
|
||||||
|
<view class="menu-item" bindtap="navTo" 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">
|
||||||
|
<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">
|
||||||
|
<text class="menu-label">设置</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 功能 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">功能</view>
|
<view class="section-title">功能</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune">
|
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune">
|
||||||
@@ -91,10 +75,9 @@
|
|||||||
<!-- 关于 -->
|
<!-- 关于 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">关于</view>
|
<view class="section-title">关于</view>
|
||||||
<view class="text-sm text-muted">
|
<view class="text-xs text-muted">
|
||||||
<text>老黄历小程序 v{{version}}</text>
|
<text>老黄历 v{{version}}</text>
|
||||||
<text class="block mt-1" wx:if="{{buildNumber !== '0'}}">构建 #{{buildNumber}} ({{commitSha}})</text>
|
<text class="block mt-1" wx:if="{{buildNumber !== '0'}}">构建 #{{buildNumber}} ({{commitSha}})</text>
|
||||||
<text class="block mt-1">提供农历、黄历、八字、许愿等功能</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,56 +1,41 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: 20rpx;
|
padding: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 8rpx;
|
||||||
color: #C41E3A;
|
color: #C41E3A;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 80rpx;
|
width: 72rpx;
|
||||||
height: 80rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item {
|
.avatar-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20rpx 0;
|
justify-content: center;
|
||||||
border-bottom: 1rpx solid #E8D5C4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-label {
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline.active {
|
|
||||||
background: #C41E3A;
|
background: #C41E3A;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #C41E3A;
|
font-size: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmark-item {
|
.btn-sm {
|
||||||
padding: 16rpx 0;
|
padding: 10rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #E8D5C4;
|
font-size: 26rpx;
|
||||||
}
|
border-radius: 32rpx;
|
||||||
|
white-space: nowrap;
|
||||||
.bookmark-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 24rpx 0;
|
padding: 18rpx 0;
|
||||||
border-bottom: 1rpx solid #E8D5C4;
|
border-bottom: 1rpx solid #E8D5C4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +44,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-label {
|
.menu-label {
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9E8E7E;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-arrow {
|
.menu-arrow {
|
||||||
color: #C9B8A6;
|
color: #C9B8A6;
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,18 @@ Page({
|
|||||||
// 按日期排序
|
// 按日期排序
|
||||||
terms.sort((a, b) => new Date(a.date) - new Date(b.date));
|
terms.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
this.setData({ terms });
|
this.setData({ terms });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '二十四节气 - 节气查询',
|
||||||
|
path: '/pages/solar-terms/solar-terms'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '二十四节气 - 节气查询'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+94
-1
@@ -1,4 +1,5 @@
|
|||||||
const { SOLAR_TERMS, LUNAR_FESTIVALS, SOLAR_FESTIVALS, HEAVEN_STEMS, EARTH_BRANCHES, ZODIACS } = require('../../utils/core/data.js');
|
const { SOLAR_TERMS, LUNAR_FESTIVALS, SOLAR_FESTIVALS, HEAVEN_STEMS, EARTH_BRANCHES, ZODIACS } = require('../../utils/core/data.js');
|
||||||
|
const { request } = require('../../utils/request.js');
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: 'all', name: '全部' },
|
{ key: 'all', name: '全部' },
|
||||||
@@ -6,9 +7,16 @@ const CATEGORIES = [
|
|||||||
{ key: 'lunarFest', name: '农历节日' },
|
{ key: 'lunarFest', name: '农历节日' },
|
||||||
{ key: 'solarFest', name: '公历节日' },
|
{ key: 'solarFest', name: '公历节日' },
|
||||||
{ key: 'ganzhi', name: '干支生肖' },
|
{ key: 'ganzhi', name: '干支生肖' },
|
||||||
{ key: 'termExplain', name: '黄历术语' }
|
{ key: 'termExplain', name: '黄历术语' },
|
||||||
|
{ key: 'other', name: '其他' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 服务端分类 key 到展示名的映射
|
||||||
|
const CAT_NAMES = {
|
||||||
|
term: '节气', lunarFest: '农历节日', solarFest: '公历节日',
|
||||||
|
ganzhi: '干支生肖', termExplain: '术语', other: '其他'
|
||||||
|
};
|
||||||
|
|
||||||
const TERM_BRIEFS = {
|
const TERM_BRIEFS = {
|
||||||
'立春': '春季开始,万物复苏', '雨水': '降雨增多,气温回升', '惊蛰': '春雷惊醒蛰伏动物',
|
'立春': '春季开始,万物复苏', '雨水': '降雨增多,气温回升', '惊蛰': '春雷惊醒蛰伏动物',
|
||||||
'春分': '昼夜平分,春季过半', '清明': '天气晴朗,草木繁茂', '谷雨': '雨生百谷,播种时节',
|
'春分': '昼夜平分,春季过半', '清明': '天气晴朗,草木繁茂', '谷雨': '雨生百谷,播种时节',
|
||||||
@@ -33,6 +41,44 @@ const TERM_EXPLAINS = [
|
|||||||
{ title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' }
|
{ title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 节气扩展字段的展示顺序与中文标签
|
||||||
|
const EXTRA_LABELS = [
|
||||||
|
{ key: 'alias', name: '别名' },
|
||||||
|
{ key: 'english', name: '外文名' },
|
||||||
|
{ key: 'meaning', name: '涵义' },
|
||||||
|
{ key: 'solarDate', name: '公历时间' },
|
||||||
|
{ key: 'ecliptic', name: '黄道位置' },
|
||||||
|
{ key: 'climate', name: '气候特点' },
|
||||||
|
{ key: 'phenology', name: '物候现象' },
|
||||||
|
{ key: 'farming', name: '农事活动' },
|
||||||
|
{ key: 'customs', name: '传统习俗' },
|
||||||
|
{ key: 'health', name: '起居养生' },
|
||||||
|
{ key: 'flower', name: '花信' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// buildExtraList 把服务端 extra JSON 字符串解析为 [{name, value}] 展示列表(仅保留非空项,按定义顺序;meaning 单独展示故排除)
|
||||||
|
function buildExtraList(extraStr) {
|
||||||
|
if (!extraStr) return [];
|
||||||
|
let obj = {};
|
||||||
|
try { obj = JSON.parse(extraStr); } catch (e) { return []; }
|
||||||
|
const list = [];
|
||||||
|
EXTRA_LABELS.forEach(f => {
|
||||||
|
if (f.key === 'meaning') return; // 涵义在引言区单独突出显示
|
||||||
|
const v = (obj[f.key] || '').trim();
|
||||||
|
if (v) list.push({ name: f.name, value: v });
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMeaning 从 extra JSON 中提取涵义(用于引言区突出显示)
|
||||||
|
function extractMeaning(extraStr) {
|
||||||
|
if (!extraStr) return '';
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(extraStr);
|
||||||
|
return (obj.meaning || '').trim();
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
function buildEntries() {
|
function buildEntries() {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
|
||||||
@@ -112,6 +158,40 @@ Page({
|
|||||||
onLoad() {
|
onLoad() {
|
||||||
const entries = buildEntries();
|
const entries = buildEntries();
|
||||||
this.setData({ entries, filteredEntries: entries });
|
this.setData({ entries, filteredEntries: entries });
|
||||||
|
this.loadServerEntries();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 从服务端加载百科条目(管理后台可维护),与本地数据合并:同标题以服务端为准,新增条目追加 */
|
||||||
|
loadServerEntries() {
|
||||||
|
request({ url: '/api/wiki/entries' })
|
||||||
|
.then(res => {
|
||||||
|
if (!res || res.code !== 0 || !res.data || !res.data.list) return;
|
||||||
|
const merged = [...this.data.entries];
|
||||||
|
const titleIndex = {};
|
||||||
|
merged.forEach((e, i) => { titleIndex[e.title] = i; });
|
||||||
|
|
||||||
|
res.data.list.forEach(s => {
|
||||||
|
const entry = {
|
||||||
|
cat: s.category,
|
||||||
|
catName: CAT_NAMES[s.category] || s.category,
|
||||||
|
title: s.title,
|
||||||
|
brief: s.brief,
|
||||||
|
content: s.content,
|
||||||
|
image: s.image || '',
|
||||||
|
meaning: extractMeaning(s.extra),
|
||||||
|
extraList: buildExtraList(s.extra),
|
||||||
|
open: false
|
||||||
|
};
|
||||||
|
if (titleIndex[s.title] !== undefined) {
|
||||||
|
merged[titleIndex[s.title]] = entry; // 服务端内容覆盖本地
|
||||||
|
} else {
|
||||||
|
merged.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setData({ entries: merged }, () => this.filter());
|
||||||
|
})
|
||||||
|
.catch(() => { /* 网络失败时使用本地内置数据 */ });
|
||||||
},
|
},
|
||||||
|
|
||||||
switchCat(e) {
|
switchCat(e) {
|
||||||
@@ -134,5 +214,18 @@ Page({
|
|||||||
const index = e.currentTarget.dataset.index;
|
const index = e.currentTarget.dataset.index;
|
||||||
const key = `filteredEntries[${index}].open`;
|
const key = `filteredEntries[${index}].open`;
|
||||||
this.setData({ [key]: !this.data.filteredEntries[index].open });
|
this.setData({ [key]: !this.data.filteredEntries[index].open });
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '黄历百科 - 传统文化知识',
|
||||||
|
path: '/pages/wiki/wiki'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '黄历百科 - 传统文化知识'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,28 @@
|
|||||||
<text class="badge badge-outline">{{item.catName}}</text>
|
<text class="badge badge-outline">{{item.catName}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view>
|
<view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view>
|
||||||
<view class="entry-detail mt-2" wx:if="{{item.open}}">
|
<view class="entry-detail" wx:if="{{item.open}}">
|
||||||
<text class="text-base">{{item.content}}</text>
|
<!-- 配图(仅服务端条目有图时显示) -->
|
||||||
|
<image wx:if="{{item.image}}" class="entry-image" src="{{item.image}}" mode="widthFix" lazy-load/>
|
||||||
|
|
||||||
|
<!-- 涵义引言(节气有 meaning 时突出显示) -->
|
||||||
|
<view class="entry-meaning" wx:if="{{item.meaning}}">
|
||||||
|
<text class="meaning-text">{{item.meaning}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 详细简介 -->
|
||||||
|
<text class="text-base entry-content">{{item.content}}</text>
|
||||||
|
|
||||||
|
<!-- 结构化扩展字段(节气:物候/习俗/养生等) -->
|
||||||
|
<view class="extra-list" wx:if="{{item.extraList && item.extraList.length > 0}}">
|
||||||
|
<view class="extra-item" wx:for="{{item.extraList}}" wx:key="name" wx:for-item="field">
|
||||||
|
<view class="extra-name">
|
||||||
|
<text class="extra-dot"></text>
|
||||||
|
<text>{{field.name}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="extra-value">{{field.value}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,92 @@
|
|||||||
|
|
||||||
.entry-detail {
|
.entry-detail {
|
||||||
border-top: 1rpx solid #F0E6DA;
|
border-top: 1rpx solid #F0E6DA;
|
||||||
padding-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节气配图 */
|
||||||
|
.entry-image {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
background: #F5F0EA;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(74, 64, 54, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 涵义引言(突出显示) */
|
||||||
|
.entry-meaning {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #FBF3EC 0%, #F7ECE2 100%);
|
||||||
|
border-left: 6rpx solid #C41E3A;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #8C6D4F;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详细简介 */
|
||||||
|
.entry-content {
|
||||||
|
display: block;
|
||||||
|
color: #4A4036;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: justify;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 结构化扩展字段列表 */
|
||||||
|
.extra-list {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
background: #FBF7F1;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-bottom: 1rpx solid #F0E6DA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 150rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #A08B76;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签前的小圆点装饰 */
|
||||||
|
.extra-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #C41E3A;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4A4036;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,4 +53,11 @@ Page({
|
|||||||
path: `/pages/wish-detail/wish-detail?id=${wish ? wish.id : ''}`,
|
path: `/pages/wish-detail/wish-detail?id=${wish ? wish.id : ''}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
const { wish } = this.data;
|
||||||
|
return {
|
||||||
|
title: wish ? `我的愿望:${wish.content.substring(0, 20)}...` : '快来许愿吧!'
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+379
-144
@@ -1,46 +1,44 @@
|
|||||||
const { request } = require('../../utils/request.js');
|
const { request, BASE_URL } = require('../../utils/request.js');
|
||||||
|
|
||||||
// 预设的树冠挂载点(百分比坐标)
|
// 树冠挂载点(基于正方形背景图的图片坐标,百分比)
|
||||||
|
// x 需落在 22~78 之间,避免竖屏 aspectFill 时被左右裁掉
|
||||||
const CANOPY_SLOTS = [
|
const CANOPY_SLOTS = [
|
||||||
{ x: 16, y: 34 },
|
{ x: 30, y: 30 }, { x: 36, y: 20 }, { x: 43, y: 14 }, { x: 50, y: 12 },
|
||||||
{ x: 27, y: 21 },
|
{ x: 57, y: 15 }, { x: 64, y: 22 }, { x: 70, y: 32 }, { x: 27, y: 42 },
|
||||||
{ x: 39, y: 14 },
|
{ x: 34, y: 38 }, { x: 42, y: 34 }, { x: 58, y: 34 }, { x: 66, y: 40 },
|
||||||
{ x: 52, y: 12 },
|
{ x: 73, y: 48 }, { x: 27, y: 55 }, { x: 47, y: 26 }, { x: 53, y: 40 },
|
||||||
{ x: 63, y: 18 },
|
{ x: 39, y: 48 }, { x: 61, y: 50 }, { x: 45, y: 56 }, { x: 31, y: 18 },
|
||||||
{ x: 74, y: 26 },
|
{ x: 60, y: 26 }, { x: 68, y: 44 }, { x: 40, y: 24 }, { x: 55, y: 20 },
|
||||||
{ x: 84, y: 38 },
|
{ x: 48, y: 44 }, { x: 35, y: 52 }, { x: 63, y: 56 }, { x: 29, y: 34 },
|
||||||
{ x: 12, y: 50 },
|
{ x: 71, y: 40 }, { x: 52, y: 30 }, { x: 44, y: 50 },
|
||||||
{ x: 23, y: 46 },
|
|
||||||
{ x: 34, y: 40 },
|
|
||||||
{ x: 66, y: 40 },
|
|
||||||
{ x: 78, y: 50 },
|
|
||||||
{ x: 88, y: 30 },
|
|
||||||
{ x: 45, y: 30 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 标签颜色配置
|
// 便签配色
|
||||||
const TAG_COLORS = {
|
const TAG_COLORS = [
|
||||||
amber: {
|
{
|
||||||
color: '#ffd700',
|
color: '#ffd700',
|
||||||
bgColor: 'rgba(255, 215, 0, 0.9)',
|
bgColor: 'rgba(255, 224, 130, 0.92)',
|
||||||
borderColor: 'rgba(255, 215, 0, 0.6)',
|
borderColor: 'rgba(255, 215, 0, 0.5)',
|
||||||
shadowColor: 'rgba(255, 215, 0, 0.4)',
|
shadowColor: 'rgba(255, 215, 0, 0.35)',
|
||||||
},
|
},
|
||||||
red: {
|
{
|
||||||
color: '#ff6b6b',
|
color: '#ff6b6b',
|
||||||
bgColor: 'rgba(255, 107, 107, 0.9)',
|
bgColor: 'rgba(255, 150, 150, 0.92)',
|
||||||
borderColor: 'rgba(255, 107, 107, 0.6)',
|
borderColor: 'rgba(255, 107, 107, 0.5)',
|
||||||
shadowColor: 'rgba(255, 107, 107, 0.4)',
|
shadowColor: 'rgba(255, 107, 107, 0.35)',
|
||||||
},
|
},
|
||||||
jade: {
|
{
|
||||||
color: '#51cf66',
|
color: '#51cf66',
|
||||||
bgColor: 'rgba(81, 207, 102, 0.9)',
|
bgColor: 'rgba(140, 226, 155, 0.92)',
|
||||||
borderColor: 'rgba(81, 207, 102, 0.6)',
|
borderColor: 'rgba(81, 207, 102, 0.5)',
|
||||||
shadowColor: 'rgba(81, 207, 102, 0.4)',
|
shadowColor: 'rgba(81, 207, 102, 0.35)',
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
|
|
||||||
// 生成伪随机数(用于萤火虫和花瓣位置)
|
// 挂树数量上限,超出的以弹幕展示
|
||||||
|
const MAX_HANG = 30;
|
||||||
|
|
||||||
|
// 伪随机(保证每次渲染一致)
|
||||||
function seeded(i, salt) {
|
function seeded(i, salt) {
|
||||||
const v = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
const v = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
||||||
return v - Math.floor(v);
|
return v - Math.floor(v);
|
||||||
@@ -48,9 +46,12 @@ function seeded(i, salt) {
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
wishes: [],
|
trees: [],
|
||||||
recentWishes: [],
|
currentTreeIndex: 0,
|
||||||
loading: true,
|
currentDanmaku: [],
|
||||||
|
topWishes: [],
|
||||||
|
detailWish: null,
|
||||||
|
fireflies: [],
|
||||||
showCreateModal: false,
|
showCreateModal: false,
|
||||||
wishContent: '',
|
wishContent: '',
|
||||||
wishType: 'free',
|
wishType: 'free',
|
||||||
@@ -58,25 +59,51 @@ Page({
|
|||||||
maxPaidLength: 100,
|
maxPaidLength: 100,
|
||||||
products: [],
|
products: [],
|
||||||
selectedProduct: null,
|
selectedProduct: null,
|
||||||
fireflies: [],
|
|
||||||
petals: [],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
// 屏幕尺寸,用于图片坐标到视口坐标的换算
|
||||||
|
const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
|
||||||
|
this.vw = sys.windowWidth;
|
||||||
|
this.vh = sys.windowHeight;
|
||||||
|
|
||||||
|
// 每棵树的许愿缓存:{ treeId: { hangWishes, danmaku, top } }
|
||||||
|
this.wishCache = {};
|
||||||
|
|
||||||
this.initAmbiance();
|
this.initAmbiance();
|
||||||
this.loadWishes();
|
this.loadTrees();
|
||||||
this.loadProducts();
|
this.loadProducts();
|
||||||
},
|
},
|
||||||
|
|
||||||
onPullDownRefresh() {
|
onUnload() {
|
||||||
this.loadWishes().then(() => {
|
this.stopDanmakuScheduler();
|
||||||
wx.stopPullDownRefresh();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 初始化氛围效果
|
onHide() {
|
||||||
|
this.stopDanmakuScheduler();
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
// 从后台/下一页返回时,若已有弹幕数据则恢复调度
|
||||||
|
if (this.danmakuRows && this.danmakuRows.length && !(this.danmakuTimers && this.danmakuTimers.length)) {
|
||||||
|
this.startDanmakuScheduler(this.danmakuRows);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPullDownRefresh() {
|
||||||
|
const { currentTreeIndex, trees } = this.data;
|
||||||
|
const tree = trees[currentTreeIndex];
|
||||||
|
if (tree) {
|
||||||
|
this.loadTreeWishes(currentTreeIndex, true).then(() => {
|
||||||
|
wx.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wx.stopPullDownRefresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 初始化萤火虫氛围
|
||||||
initAmbiance() {
|
initAmbiance() {
|
||||||
// 萤火虫
|
|
||||||
const fireflies = [];
|
const fireflies = [];
|
||||||
for (let i = 0; i < 22; i++) {
|
for (let i = 0; i < 22; i++) {
|
||||||
fireflies.push({
|
fireflies.push({
|
||||||
@@ -87,119 +114,330 @@ Page({
|
|||||||
delay: seeded(i, 5) * 4,
|
delay: seeded(i, 5) * 4,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.setData({ fireflies });
|
||||||
// 花瓣
|
|
||||||
const petals = [];
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
petals.push({
|
|
||||||
left: seeded(i, 6) * 100,
|
|
||||||
size: 5 + seeded(i, 7) * 6,
|
|
||||||
duration: 10 + seeded(i, 8) * 8,
|
|
||||||
delay: seeded(i, 9) * 12,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setData({ fireflies, petals });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 加载许愿
|
// 图片坐标(正方形图)→ 视口坐标
|
||||||
async loadWishes() {
|
// aspectFill 下竖屏会左右对称裁剪,横向需要按比例展开
|
||||||
|
transformSlot(slot) {
|
||||||
|
const r = this.vh / this.vw;
|
||||||
|
const x = slot.x * r - (r - 1) * 50;
|
||||||
|
return {
|
||||||
|
x: Math.min(96, Math.max(4, x)),
|
||||||
|
y: slot.y,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载许愿树列表(后端提供几个就显示几个分屏)
|
||||||
|
async loadTrees() {
|
||||||
|
try {
|
||||||
|
const res = await request({ url: '/api/wish/trees', method: 'GET' });
|
||||||
|
if (res.code === 0 && res.data.list && res.data.list.length > 0) {
|
||||||
|
const trees = res.data.list.map((t) => ({
|
||||||
|
...t,
|
||||||
|
imageUrl: this.resolveImageUrl(t.image),
|
||||||
|
hangWishes: [],
|
||||||
|
}));
|
||||||
|
this.setData({ trees });
|
||||||
|
this.loadTreeWishes(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载许愿树失败:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:单棵本地树
|
||||||
|
this.setData({
|
||||||
|
trees: [{
|
||||||
|
id: 1,
|
||||||
|
name: '祈福树',
|
||||||
|
imageUrl: '/images/wish-tree.jpg',
|
||||||
|
hangWishes: [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
this.loadTreeWishes(0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 解析树背景图地址(相对路径拼接服务器域名)
|
||||||
|
resolveImageUrl(image) {
|
||||||
|
if (!image) return '/images/wish-tree.jpg';
|
||||||
|
if (/^https?:\/\//.test(image)) return image;
|
||||||
|
return BASE_URL + image;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 分屏切换
|
||||||
|
onSwiperChange(e) {
|
||||||
|
const index = e.detail.current;
|
||||||
|
this.setData({ currentTreeIndex: index });
|
||||||
|
|
||||||
|
const tree = this.data.trees[index];
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
const cache = this.wishCache[tree.id];
|
||||||
|
if (cache) {
|
||||||
|
// 已加载过,直接展示缓存
|
||||||
|
this.applyTreeWishes(index, cache);
|
||||||
|
} else {
|
||||||
|
this.loadTreeWishes(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载指定树的许愿(默认100条)
|
||||||
|
async loadTreeWishes(index, force) {
|
||||||
|
const trees = this.data.trees;
|
||||||
|
const tree = trees[index];
|
||||||
|
if (!tree) return;
|
||||||
|
if (!force && this.wishCache[tree.id]) {
|
||||||
|
this.applyTreeWishes(index, this.wishCache[tree.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = [];
|
||||||
try {
|
try {
|
||||||
const res = await request({
|
const res = await request({
|
||||||
url: '/api/wish/tree/1/wishes',
|
url: `/api/wish/tree/${tree.id}/wishes`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const wishes = this.formatWishes(res.data.list || []);
|
list = res.data.list || [];
|
||||||
this.setData({
|
|
||||||
wishes,
|
|
||||||
recentWishes: wishes.slice(0, 8),
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载许愿失败:', err);
|
console.error('加载许愿失败:', err);
|
||||||
const wishes = this.formatWishes(this.getMockWishes());
|
list = this.getMockWishes();
|
||||||
this.setData({
|
}
|
||||||
wishes,
|
|
||||||
recentWishes: wishes.slice(0, 8),
|
// 按时间倒序(新数据在前)
|
||||||
loading: false,
|
list.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||||||
|
|
||||||
|
// 顶级位置轮播
|
||||||
|
const top = list.filter((w) => w.isTop).map((w) => this.formatWish(w, 0));
|
||||||
|
|
||||||
|
// 最新30条挂树
|
||||||
|
const hangWishes = list.slice(0, MAX_HANG).map((w, i) => {
|
||||||
|
const wish = this.formatWish(w, i);
|
||||||
|
const slot = this.transformSlot(this.pickSlot(i));
|
||||||
|
return {
|
||||||
|
...wish,
|
||||||
|
x: slot.x,
|
||||||
|
y: slot.y,
|
||||||
|
swayDuration: 3.4 + (i % 5) * 0.6,
|
||||||
|
swayDelay: (i % 7) * 0.35,
|
||||||
|
isNew: Date.now() - new Date(w.createdAt || 0).getTime() < 60000,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 其余以弹幕飘过:树桩处3行 + 置顶轮播下方1行,慢速均匀间隔不重叠
|
||||||
|
const danmaku = this.buildDanmaku(list.slice(MAX_HANG));
|
||||||
|
|
||||||
|
const cache = { hangWishes, danmaku, top };
|
||||||
|
this.wishCache[tree.id] = cache;
|
||||||
|
this.applyTreeWishes(index, cache);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 应用某棵树的许愿数据到视图
|
||||||
|
applyTreeWishes(index, cache) {
|
||||||
|
this.setData({
|
||||||
|
[`trees[${index}].hangWishes`]: cache.hangWishes,
|
||||||
|
topWishes: cache.top,
|
||||||
|
});
|
||||||
|
// 启动弹幕调度:每行一条完毕后再出下一条
|
||||||
|
this.startDanmakuScheduler(cache.danmaku);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启动弹幕调度:每行一个发射定时器,前一条完全进入屏幕后就出下一条,
|
||||||
|
// 同一行屏上保持 2~3 条,首尾相接不压着;各行速度不同、同行弹幕速度也错落
|
||||||
|
startDanmakuScheduler(rows) {
|
||||||
|
this.stopDanmakuScheduler();
|
||||||
|
this.danmakuRows = rows || [];
|
||||||
|
this.danmakuTimers = [];
|
||||||
|
this.danmakuSeq = 0; // 全局自增序号,保证每条 key 唯一
|
||||||
|
|
||||||
|
if (!this.danmakuRows.length) {
|
||||||
|
this.setData({ currentDanmaku: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏上目标条数:同一行同时约 2.5 条
|
||||||
|
const ON_SCREEN = 2.5;
|
||||||
|
// 弹幕完全进入屏幕约需飘过总路程的 30%(不压着前一条的最小间隔)
|
||||||
|
const ENTER_RATIO = 0.3;
|
||||||
|
const MIN_INTERVAL = 3; // 同一行相邻两条最小间隔(秒)
|
||||||
|
|
||||||
|
// 往某行发射一条弹幕,返回该条的 duration(供调度下一条用)
|
||||||
|
const emit = (row, idx) => {
|
||||||
|
const rowData = this.danmakuRows[row];
|
||||||
|
if (!rowData) return 0;
|
||||||
|
const item = rowData.list[idx % rowData.list.length];
|
||||||
|
const seq = ++this.danmakuSeq;
|
||||||
|
const danmaku = {
|
||||||
|
key: `d${seq}`,
|
||||||
|
id: item.id,
|
||||||
|
content: item.content,
|
||||||
|
author: item.author,
|
||||||
|
type: item.type,
|
||||||
|
top: rowData.top,
|
||||||
|
duration: item.duration,
|
||||||
|
};
|
||||||
|
// 追加到屏上弹幕列表(构造新数组,避免索引冲突)
|
||||||
|
this.setData({ currentDanmaku: this.data.currentDanmaku.concat(danmaku) });
|
||||||
|
// 动画播完(飘到屏外)后,把这条从列表移除
|
||||||
|
const removeTimer = setTimeout(() => {
|
||||||
|
const list = this.data.currentDanmaku.filter((d) => d.key !== danmaku.key);
|
||||||
|
this.setData({ currentDanmaku: list });
|
||||||
|
}, item.duration * 1000);
|
||||||
|
this.danmakuTimers.push(removeTimer);
|
||||||
|
return item.duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每行独立调度:按刚发射这条的速度决定下一条间隔,循环
|
||||||
|
this.danmakuRows.forEach((rowData, row) => {
|
||||||
|
let idx = 0;
|
||||||
|
const loop = () => {
|
||||||
|
const duration = emit(row, idx);
|
||||||
|
idx += 1;
|
||||||
|
// 下一条间隔:屏上保持约 2.5 条(duration/2.5),
|
||||||
|
// 但不小于“完全进入”时间(duration*0.3,不压着)和最小间隔
|
||||||
|
const interval = Math.max(duration / ON_SCREEN, duration * ENTER_RATIO, MIN_INTERVAL);
|
||||||
|
const timer = setTimeout(loop, interval * 1000);
|
||||||
|
this.danmakuTimers.push(timer);
|
||||||
|
};
|
||||||
|
// 各行错开启动,避免 4 行同时从右侧涌出
|
||||||
|
const startTimer = setTimeout(loop, row * 1500);
|
||||||
|
this.danmakuTimers.push(startTimer);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 停止弹幕调度
|
||||||
|
stopDanmakuScheduler() {
|
||||||
|
if (this.danmakuTimers) {
|
||||||
|
this.danmakuTimers.forEach((t) => clearTimeout(t));
|
||||||
|
this.danmakuTimers = [];
|
||||||
|
}
|
||||||
|
// 清空屏上弹幕,避免恢复时残留未播完的项
|
||||||
|
if (this.data.currentDanmaku && this.data.currentDanmaku.length) {
|
||||||
|
this.setData({ currentDanmaku: [] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 格式化许愿数据
|
// 构建弹幕:共 4 行(行 0-2 树桩处三行,行 3 顶部置顶轮播下方)
|
||||||
formatWishes(list) {
|
// 每行速度不同、同行弹幕速度也带随机错落,避免太整齐;前一条完全进入后出下一条
|
||||||
const colorKeys = Object.keys(TAG_COLORS);
|
buildDanmaku(list) {
|
||||||
|
// 行位置(屏幕百分比):
|
||||||
|
// - 行3 顶部行紧贴置顶轮播下方(稍有空隙)
|
||||||
|
// - 行0-2 树桩处三行,第一条70%,三行紧凑(间隔3%)
|
||||||
|
const ROW_TOPS = [70, 73, 76, 12];
|
||||||
|
// 每行基准速度(秒/次):各行不同,避免整齐划一
|
||||||
|
const ROW_BASE_DURATION = [26, 33, 40, 22];
|
||||||
|
|
||||||
return list.map((wish, index) => {
|
// 轮流分配到 4 行
|
||||||
const slot = this.pickSlot(index);
|
const rows = [[], [], [], []];
|
||||||
const colorKey = colorKeys[index % colorKeys.length];
|
list.forEach((w, i) => {
|
||||||
const colors = TAG_COLORS[colorKey];
|
rows[i % 4].push(w);
|
||||||
|
|
||||||
return {
|
|
||||||
id: wish.id,
|
|
||||||
content: wish.content,
|
|
||||||
author: wish.author || '匿名',
|
|
||||||
type: wish.type,
|
|
||||||
x: slot.x,
|
|
||||||
y: slot.y,
|
|
||||||
color: colors.color,
|
|
||||||
bgColor: colors.bgColor,
|
|
||||||
borderColor: colors.borderColor,
|
|
||||||
shadowColor: colors.shadowColor,
|
|
||||||
swayDuration: 3.4 + (index % 5) * 0.6,
|
|
||||||
swayDelay: (index % 7) * 0.35,
|
|
||||||
isNew: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 每行存该行的所有弹幕(带位置/时长),供定时器轮流播放
|
||||||
|
return rows.map((rowList, row) => ({
|
||||||
|
top: ROW_TOPS[row],
|
||||||
|
list: rowList.map((w, i) => ({
|
||||||
|
id: w.id,
|
||||||
|
content: w.content,
|
||||||
|
author: w.author || '匿名',
|
||||||
|
type: w.type,
|
||||||
|
// 同行弹幕速度也带 ±20% 错落(seeded 保证每次渲染一致)
|
||||||
|
duration: Math.round(ROW_BASE_DURATION[row] * (0.8 + seeded(w.id != null ? w.id : i, row + 7) * 0.4)),
|
||||||
|
})),
|
||||||
|
})).filter((r) => r.list.length > 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择挂载点
|
// 格式化许愿
|
||||||
|
formatWish(w, i) {
|
||||||
|
const colors = TAG_COLORS[i % TAG_COLORS.length];
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
content: w.content,
|
||||||
|
author: w.author || '匿名',
|
||||||
|
type: w.type,
|
||||||
|
isTop: !!w.isTop,
|
||||||
|
dateStr: this.formatDate(w.createdAt),
|
||||||
|
...colors,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 选择挂载点(循环复用,复用时加抖动)
|
||||||
pickSlot(index) {
|
pickSlot(index) {
|
||||||
const base = CANOPY_SLOTS[index % CANOPY_SLOTS.length];
|
const base = CANOPY_SLOTS[index % CANOPY_SLOTS.length];
|
||||||
const wrap = Math.floor(index / CANOPY_SLOTS.length);
|
const wrap = Math.floor(index / CANOPY_SLOTS.length);
|
||||||
|
|
||||||
if (wrap === 0) return base;
|
if (wrap === 0) return base;
|
||||||
|
const jitterX = ((wrap * 37) % 7) - 3;
|
||||||
const jitterX = ((wrap * 37) % 9) - 4;
|
const jitterY = ((wrap * 53) % 7) - 3;
|
||||||
const jitterY = ((wrap * 53) % 9) - 4;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: Math.min(92, Math.max(8, base.x + jitterX)),
|
x: Math.min(75, Math.max(25, base.x + jitterX)),
|
||||||
y: Math.min(58, Math.max(10, base.y + jitterY)),
|
y: Math.min(60, Math.max(10, base.y + jitterY)),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// 模拟许愿数据
|
// 日期格式化
|
||||||
|
formatDate(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
const d = new Date(t);
|
||||||
|
const pad = (n) => (n < 10 ? '0' + n : '' + n);
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 模拟数据(开发调试用,60条以验证弹幕效果)
|
||||||
getMockWishes() {
|
getMockWishes() {
|
||||||
return [
|
const texts = [
|
||||||
{ id: 1, content: '愿家人平安喜乐,身体健康', author: '小满', type: 'paid' },
|
'愿家人平安喜乐,身体健康',
|
||||||
{ id: 2, content: '希望今年考研上岸,一战成硕', author: '阿远', type: 'free' },
|
'希望今年考研上岸,一战成硕',
|
||||||
{ id: 3, content: '愿世界温柔以待每一个努力的人', author: '林深', type: 'paid' },
|
'愿世界温柔以待每一个努力的人',
|
||||||
{ id: 4, content: '早日遇见那个对的人', author: '拾光', type: 'free' },
|
'早日遇见那个对的人',
|
||||||
{ id: 5, content: '祝爸妈身体硬朗,笑口常开', author: '念念', type: 'paid' },
|
'祝爸妈身体硬朗,笑口常开',
|
||||||
{ id: 6, content: '愿所求皆如愿,所行皆坦途', author: '白露', type: 'free' },
|
'愿所求皆如愿,所行皆坦途',
|
||||||
{ id: 7, content: '希望新工作顺顺利利', author: '子夜', type: 'paid' },
|
'希望新工作顺顺利利',
|
||||||
{ id: 8, content: '愿此生尽兴,赤诚善良', author: '青禾', type: 'free' },
|
'愿此生尽兴,赤诚善良',
|
||||||
{ id: 9, content: '愿代码零bug,一次通过', author: '小测', type: 'paid' },
|
'愿代码零bug,一次通过',
|
||||||
|
'希望减肥成功,越来越好看',
|
||||||
|
'愿所有付出都有回报',
|
||||||
|
'希望今年能去一次海边',
|
||||||
];
|
];
|
||||||
|
const authors = ['小满', '阿远', '林深', '拾光', '念念', '白露', '子夜', '青禾', '小测', '桃桃', '余涵', '奕'];
|
||||||
|
const list = [];
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
list.push({
|
||||||
|
id: i + 1,
|
||||||
|
content: texts[i % texts.length],
|
||||||
|
author: authors[i % authors.length],
|
||||||
|
type: i % 5 === 0 ? 'paid' : 'free',
|
||||||
|
position: i === 0 ? 100 : 0,
|
||||||
|
isTop: i === 0,
|
||||||
|
createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 查看便签详情
|
||||||
|
openDetail(e) {
|
||||||
|
const wish = e.currentTarget.dataset.wish;
|
||||||
|
if (!wish) return;
|
||||||
|
this.setData({ detailWish: wish });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeDetail() {
|
||||||
|
this.setData({ detailWish: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 加载许愿商品
|
// 加载许愿商品
|
||||||
async loadProducts() {
|
async loadProducts() {
|
||||||
try {
|
try {
|
||||||
const res = await request({
|
const res = await request({ url: '/api/wish/products', method: 'GET' });
|
||||||
url: '/api/wish/products',
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
this.setData({ products: res.data.list || [] });
|
this.setData({ products: res.data.list || [] });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载商品失败:', err);
|
console.error('加载商品失败:', err);
|
||||||
|
}
|
||||||
this.setData({
|
this.setData({
|
||||||
products: [
|
products: [
|
||||||
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
|
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
|
||||||
@@ -207,15 +445,13 @@ Page({
|
|||||||
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
|
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 打开许愿弹窗
|
// 打开/关闭许愿弹窗
|
||||||
openCreateModal() {
|
openCreateModal() {
|
||||||
this.setData({ showCreateModal: true });
|
this.setData({ showCreateModal: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 关闭许愿弹窗
|
|
||||||
closeCreateModal() {
|
closeCreateModal() {
|
||||||
this.setData({
|
this.setData({
|
||||||
showCreateModal: false,
|
showCreateModal: false,
|
||||||
@@ -225,34 +461,37 @@ Page({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 输入许愿内容
|
|
||||||
onInputContent(e) {
|
onInputContent(e) {
|
||||||
this.setData({ wishContent: e.detail.value });
|
this.setData({ wishContent: e.detail.value });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择许愿类型
|
|
||||||
selectType(e) {
|
selectType(e) {
|
||||||
const type = e.currentTarget.dataset.type;
|
this.setData({ wishType: e.currentTarget.dataset.type });
|
||||||
this.setData({ wishType: type });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 选择商品
|
|
||||||
selectProduct(e) {
|
selectProduct(e) {
|
||||||
const id = e.currentTarget.dataset.id;
|
const id = e.currentTarget.dataset.id;
|
||||||
const product = this.data.products.find(p => p.id === id);
|
const product = this.data.products.find((p) => p.id === id);
|
||||||
this.setData({ selectedProduct: product });
|
this.setData({ selectedProduct: product });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 当前树ID
|
||||||
|
currentTreeId() {
|
||||||
|
const { trees, currentTreeIndex } = this.data;
|
||||||
|
const tree = trees[currentTreeIndex];
|
||||||
|
return tree ? tree.id : 1;
|
||||||
|
},
|
||||||
|
|
||||||
// 提交许愿
|
// 提交许愿
|
||||||
async submitWish() {
|
async submitWish() {
|
||||||
const { wishContent, wishType, selectedProduct } = this.data;
|
const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data;
|
||||||
|
|
||||||
if (!wishContent.trim()) {
|
if (!wishContent.trim()) {
|
||||||
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
|
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLength = wishType === 'free' ? this.data.maxFreeLength : this.data.maxPaidLength;
|
const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength;
|
||||||
if (wishContent.length > maxLength) {
|
if (wishContent.length > maxLength) {
|
||||||
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
|
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
|
||||||
return;
|
return;
|
||||||
@@ -272,10 +511,9 @@ Page({
|
|||||||
type: 'wish',
|
type: 'wish',
|
||||||
productId: selectedProduct.id,
|
productId: selectedProduct.id,
|
||||||
content: wishContent,
|
content: wishContent,
|
||||||
treeId: 1,
|
treeId: this.currentTreeId(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (orderRes.code === 0) {
|
if (orderRes.code === 0) {
|
||||||
await this.wxPay(orderRes.data);
|
await this.wxPay(orderRes.data);
|
||||||
}
|
}
|
||||||
@@ -286,14 +524,13 @@ Page({
|
|||||||
data: {
|
data: {
|
||||||
content: wishContent,
|
content: wishContent,
|
||||||
type: 'free',
|
type: 'free',
|
||||||
treeId: 1,
|
treeId: this.currentTreeId(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
wx.showToast({ title: '许愿成功', icon: 'success' });
|
wx.showToast({ title: '许愿成功', icon: 'success' });
|
||||||
this.closeCreateModal();
|
this.closeCreateModal();
|
||||||
this.loadWishes();
|
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -314,7 +551,7 @@ Page({
|
|||||||
success: () => {
|
success: () => {
|
||||||
wx.showToast({ title: '支付成功', icon: 'success' });
|
wx.showToast({ title: '支付成功', icon: 'success' });
|
||||||
this.closeCreateModal();
|
this.closeCreateModal();
|
||||||
this.loadWishes();
|
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
@@ -326,14 +563,6 @@ Page({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 查看许愿详情
|
|
||||||
viewWishDetail(e) {
|
|
||||||
const id = e.currentTarget.dataset.id;
|
|
||||||
wx.navigateTo({
|
|
||||||
url: `/pages/wish-detail/wish-detail?id=${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 分享
|
// 分享
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
return {
|
return {
|
||||||
@@ -341,4 +570,10 @@ Page({
|
|||||||
path: '/pages/wish-tree/wish-tree',
|
path: '/pages/wish-tree/wish-tree',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '快来许愿树许下你的愿望吧!'
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,94 +1,117 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 背景氛围:萤火虫 -->
|
<!-- 背景氛围:萤火虫 -->
|
||||||
<view class="ambiance">
|
<view class="firefly"
|
||||||
<view
|
|
||||||
wx:for="{{fireflies}}"
|
wx:for="{{fireflies}}"
|
||||||
wx:key="index"
|
wx:key="index"
|
||||||
class="firefly"
|
|
||||||
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
|
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
|
||||||
></view>
|
></view>
|
||||||
|
|
||||||
|
<!-- 多树分屏轮播 -->
|
||||||
|
<swiper
|
||||||
|
class="tree-swiper"
|
||||||
|
current="{{currentTreeIndex}}"
|
||||||
|
bindchange="onSwiperChange"
|
||||||
|
circular="{{trees.length > 1}}"
|
||||||
|
>
|
||||||
|
<swiper-item wx:for="{{trees}}" wx:key="id">
|
||||||
|
<view class="tree-page">
|
||||||
|
<!-- 背景图撑满全屏 -->
|
||||||
|
<image
|
||||||
|
src="{{item.imageUrl}}"
|
||||||
|
class="tree-bg"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 树上的许愿便签(最新30条) -->
|
||||||
|
<view
|
||||||
|
wx:for="{{item.hangWishes}}"
|
||||||
|
wx:for-item="wish"
|
||||||
|
wx:key="id"
|
||||||
|
class="wish-tag {{wish.isNew ? 'new' : ''}}"
|
||||||
|
style="left: {{wish.x}}%; top: {{wish.y}}%;"
|
||||||
|
catchtap="openDetail"
|
||||||
|
data-wish="{{wish}}"
|
||||||
|
>
|
||||||
|
<view class="tag-sway" style="animation-duration: {{wish.swayDuration}}s; animation-delay: {{wish.swayDelay}}s;">
|
||||||
|
<view class="tag-string" style="background: linear-gradient(to bottom, transparent, {{wish.color}});"></view>
|
||||||
|
<view class="tag-knot" style="background: {{wish.color}}; box-shadow: 0 0 8rpx {{wish.color}};"></view>
|
||||||
|
<view class="tag-content" style="background: {{wish.bgColor}}; border: 1rpx solid {{wish.borderColor}}; box-shadow: 0 6rpx 20rpx {{wish.shadowColor}};">
|
||||||
|
<text class="tag-text">{{wish.content}}</text>
|
||||||
|
<text class="tag-author">—— {{wish.author}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
|
||||||
|
<!-- 弹幕层(超出30条的心愿缓缓飘过,前一条完全进入后出下一条) -->
|
||||||
|
<view class="danmaku-layer">
|
||||||
|
<view
|
||||||
|
wx:for="{{currentDanmaku}}"
|
||||||
|
wx:key="key"
|
||||||
|
class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
|
||||||
|
style="top: {{item.top}}%; animation-duration: {{item.duration}}s;"
|
||||||
|
>{{item.content}} · {{item.author}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 背景氛围:花瓣 -->
|
<!-- 边缘暗角 -->
|
||||||
<view class="petals">
|
<view class="vignette"></view>
|
||||||
|
|
||||||
|
<!-- 顶部:MAKE A WISH + 顶级位置轮播 -->
|
||||||
|
<view class="header">
|
||||||
|
<text class="subtitle">MAKE A WISH</text>
|
||||||
|
<swiper
|
||||||
|
wx:if="{{topWishes.length > 0}}"
|
||||||
|
class="top-swiper"
|
||||||
|
vertical
|
||||||
|
autoplay
|
||||||
|
circular
|
||||||
|
interval="3000"
|
||||||
|
duration="500"
|
||||||
|
>
|
||||||
|
<swiper-item wx:for="{{topWishes}}" wx:key="id">
|
||||||
|
<view class="top-item" catchtap="openDetail" data-wish="{{item}}">
|
||||||
|
<text class="top-crown">👑</text>
|
||||||
|
<text class="top-text">{{item.content}}</text>
|
||||||
|
<text class="top-author">—— {{item.author}}</text>
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 分屏指示点 -->
|
||||||
|
<view class="page-dots" wx:if="{{trees.length > 1}}">
|
||||||
<view
|
<view
|
||||||
wx:for="{{petals}}"
|
wx:for="{{trees}}"
|
||||||
wx:key="index"
|
wx:key="id"
|
||||||
class="petal"
|
class="dot {{currentTreeIndex === index ? 'active' : ''}}"
|
||||||
style="left: {{item.left}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
|
|
||||||
></view>
|
></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 边缘暗角,让树从中心发光 -->
|
<!-- 许愿按钮(树根附近) -->
|
||||||
<view class="vignette"></view>
|
|
||||||
|
|
||||||
<!-- 主内容 -->
|
|
||||||
<view class="main-content">
|
|
||||||
<!-- 头部 -->
|
|
||||||
<view class="header">
|
|
||||||
<text class="subtitle">MAKE A WISH</text>
|
|
||||||
<text class="title">许愿树</text>
|
|
||||||
<text class="description">月色微凉,灯火摇曳。写下心中所愿,挂上枝头,静待花开成真。</text>
|
|
||||||
<text class="wish-count">已有 <text class="count">{{wishes.length}}</text> 个心愿挂满枝头</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 许愿树 -->
|
|
||||||
<view class="tree-container">
|
|
||||||
<image
|
|
||||||
src="/images/wish-tree.jpg"
|
|
||||||
class="tree-image"
|
|
||||||
mode="aspectFit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 许愿标签 -->
|
|
||||||
<view
|
|
||||||
wx:for="{{wishes}}"
|
|
||||||
wx:key="id"
|
|
||||||
class="wish-tag {{item.isNew ? 'new' : ''}}"
|
|
||||||
style="left: {{item.x}}%; top: {{item.y}}%;"
|
|
||||||
bindtap="viewWishDetail"
|
|
||||||
data-id="{{item.id}}"
|
|
||||||
>
|
|
||||||
<view class="tag-sway" style="animation-duration: {{item.swayDuration}}s; animation-delay: {{item.swayDelay}}s;">
|
|
||||||
<!-- 挂绳 -->
|
|
||||||
<view class="tag-string" style="background: linear-gradient(to bottom, transparent, {{item.color}});"></view>
|
|
||||||
<!-- 绳结 -->
|
|
||||||
<view class="tag-knot" style="background: {{item.color}}; box-shadow: 0 0 8px {{item.color}};"></view>
|
|
||||||
<!-- 标签 -->
|
|
||||||
<view class="tag-content" style="background: {{item.bgColor}}; border: 1px solid {{item.borderColor}}; box-shadow: 0 4px 16px {{item.shadowColor}};">
|
|
||||||
<text class="tag-text">{{item.content}}</text>
|
|
||||||
<text class="tag-author">—— {{item.author}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 最近的心愿 -->
|
|
||||||
<view class="recent-section">
|
|
||||||
<text class="section-title">最 近 的 心 愿</text>
|
|
||||||
<scroll-view class="recent-list" scroll-x enhanced show-scrollbar="{{false}}">
|
|
||||||
<view
|
|
||||||
wx:for="{{recentWishes}}"
|
|
||||||
wx:key="id"
|
|
||||||
class="recent-item"
|
|
||||||
bindtap="viewWishDetail"
|
|
||||||
data-id="{{item.id}}"
|
|
||||||
>
|
|
||||||
<text class="recent-text">{{item.content}}</text>
|
|
||||||
<text class="recent-author">—— {{item.author}}</text>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 许愿按钮 -->
|
|
||||||
<view class="action-bar">
|
<view class="action-bar">
|
||||||
<button class="btn-wish" bindtap="openCreateModal">
|
<button class="btn-wish" bindtap="openCreateModal">
|
||||||
<text class="icon">🙏</text>
|
<text class="icon">🙏</text>
|
||||||
<text>写下心愿</text>
|
<text class="btn-text">写下心愿</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 便签详情弹窗 -->
|
||||||
|
<view class="detail-modal" wx:if="{{detailWish}}">
|
||||||
|
<view class="detail-mask" bindtap="closeDetail"></view>
|
||||||
|
<view class="note-card {{detailWish.isTop ? 'top-note' : ''}}">
|
||||||
|
<view class="note-string"></view>
|
||||||
|
<view class="note-knot" style="background: {{detailWish.color}};"></view>
|
||||||
|
<view class="note-badge" wx:if="{{detailWish.isTop}}">👑 顶级心愿</view>
|
||||||
|
<text class="note-text">{{detailWish.content}}</text>
|
||||||
|
<view class="note-footer">
|
||||||
|
<text class="note-author">—— {{detailWish.author}}</text>
|
||||||
|
<text class="note-date">{{detailWish.dateStr}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 许愿弹窗 -->
|
<!-- 许愿弹窗 -->
|
||||||
<view class="modal" wx:if="{{showCreateModal}}">
|
<view class="modal" wx:if="{{showCreateModal}}">
|
||||||
<view class="modal-mask" bindtap="closeCreateModal"></view>
|
<view class="modal-mask" bindtap="closeCreateModal"></view>
|
||||||
@@ -133,7 +156,7 @@
|
|||||||
|
|
||||||
<!-- 付费商品选择 -->
|
<!-- 付费商品选择 -->
|
||||||
<view class="product-section" wx:if="{{wishType === 'paid'}}">
|
<view class="product-section" wx:if="{{wishType === 'paid'}}">
|
||||||
<view class="section-title">选择许愿商品</view>
|
<view class="product-title">选择许愿商品</view>
|
||||||
<view class="product-list">
|
<view class="product-list">
|
||||||
<view
|
<view
|
||||||
wx:for="{{products}}"
|
wx:for="{{products}}"
|
||||||
|
|||||||
+275
-188
@@ -6,41 +6,40 @@
|
|||||||
background: #0d0d1a;
|
background: #0d0d1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 背景氛围 */
|
/* 多树分屏 */
|
||||||
.ambiance {
|
.tree-swiper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景图撑满全屏 */
|
||||||
|
.tree-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 萤火虫 */
|
||||||
.firefly {
|
.firefly {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ffd700;
|
background: #ffd700;
|
||||||
box-shadow: 0 0 8px #ffd700, 0 0 14px #ffd700;
|
box-shadow: 0 0 8px #ffd700, 0 0 14px #ffd700;
|
||||||
animation: twinkle 3s ease-in-out infinite;
|
animation: twinkle 3s ease-in-out infinite;
|
||||||
}
|
|
||||||
|
|
||||||
.petals {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.petal {
|
|
||||||
position: absolute;
|
|
||||||
top: -20px;
|
|
||||||
border-radius: 50% 50% 50% 0;
|
|
||||||
background: rgba(255, 200, 200, 0.6);
|
|
||||||
animation: drift 12s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 边缘暗角 */
|
/* 边缘暗角 */
|
||||||
@@ -50,84 +49,15 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: radial-gradient(120% 90% at 50% 30%, transparent 40%, rgba(13, 13, 26, 0.9) 100%);
|
background: radial-gradient(120% 90% at 50% 30%, transparent 45%, rgba(13, 13, 26, 0.75) 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主内容 */
|
/* 许愿便签 */
|
||||||
.main-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 60rpx 40rpx 200rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头部 */
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
display: block;
|
|
||||||
font-size: 20rpx;
|
|
||||||
letter-spacing: 8rpx;
|
|
||||||
color: rgba(255, 215, 0, 0.8);
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
font-size: 72rpx;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffd700;
|
|
||||||
text-shadow: 0 4rpx 24rpx rgba(255, 215, 0, 0.4);
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
display: block;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 500rpx;
|
|
||||||
margin: 0 auto 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wish-count {
|
|
||||||
display: block;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: rgba(255, 215, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wish-count .count {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 许愿树容器 */
|
|
||||||
.tree-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700rpx;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 许愿标签 */
|
|
||||||
.wish-tag {
|
.wish-tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 5;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -148,7 +78,7 @@
|
|||||||
|
|
||||||
.tag-string {
|
.tag-string {
|
||||||
width: 2rpx;
|
width: 2rpx;
|
||||||
height: 40rpx;
|
height: 36rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-knot {
|
.tag-knot {
|
||||||
@@ -160,127 +90,298 @@
|
|||||||
|
|
||||||
.tag-content {
|
.tag-content {
|
||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
padding: 16rpx 20rpx;
|
padding: 12rpx 16rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
min-width: 160rpx;
|
width: 160rpx;
|
||||||
max-width: 200rpx;
|
box-sizing: border-box;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wish-tag:active .tag-content {
|
|
||||||
transform: translateY(-4rpx) scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-text {
|
.tag-text {
|
||||||
display: block;
|
display: -webkit-box;
|
||||||
font-size: 20rpx;
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 19rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2a2a3a;
|
color: #2a2a3a;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
word-break: break-all;
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-author {
|
.tag-author {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 16rpx;
|
font-size: 15rpx;
|
||||||
color: #4a4a5a;
|
color: rgba(42, 42, 58, 0.7);
|
||||||
margin-top: 8rpx;
|
margin-top: 6rpx;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 最近的心愿 */
|
/* 弹幕层 */
|
||||||
.recent-section {
|
.danmaku-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 40rpx;
|
height: 100%;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.danmaku-item {
|
||||||
display: block;
|
position: absolute;
|
||||||
text-align: center;
|
left: 0;
|
||||||
font-size: 22rpx;
|
|
||||||
letter-spacing: 6rpx;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-list {
|
|
||||||
display: flex;
|
|
||||||
gap: 20rpx;
|
|
||||||
padding-bottom: 20rpx;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
padding: 6rpx 24rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
background: rgba(13, 13, 26, 0.35);
|
||||||
|
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.6);
|
||||||
|
animation-name: danmaku;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-item {
|
.danmaku-item.paid {
|
||||||
display: inline-flex;
|
color: #ffd700;
|
||||||
|
background: rgba(60, 40, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部 */
|
||||||
|
.header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 40rpx 40rpx 0;
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 280rpx;
|
align-items: center;
|
||||||
padding: 24rpx;
|
pointer-events: none;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-text {
|
.subtitle {
|
||||||
|
font-size: 22rpx;
|
||||||
|
letter-spacing: 8rpx;
|
||||||
|
color: rgba(255, 215, 0, 0.85);
|
||||||
|
text-shadow: 0 2rpx 10rpx rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶级位置轮播 */
|
||||||
|
.top-swiper {
|
||||||
|
width: 100%;
|
||||||
|
height: 64rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 215, 0, 0.12), rgba(255, 215, 0, 0.25), rgba(255, 215, 0, 0.12));
|
||||||
|
border: 1rpx solid rgba(255, 215, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-crown {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
}
|
||||||
line-height: 1.5;
|
|
||||||
|
.top-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: 500;
|
||||||
|
max-width: 420rpx;
|
||||||
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-author {
|
.top-author {
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 215, 0, 0.7);
|
||||||
margin-top: 12rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 许愿按钮 */
|
/* 分屏指示点 */
|
||||||
|
.page-dots {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 190rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.active {
|
||||||
|
width: 32rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 215, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 许愿按钮(树根附近,单行) */
|
||||||
.action-bar {
|
.action-bar {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
bottom: 60rpx;
|
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translateX(-50%);
|
right: 0;
|
||||||
z-index: 100;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-wish {
|
.btn-wish {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16rpx;
|
white-space: nowrap;
|
||||||
|
gap: 12rpx;
|
||||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 50%, #ff6b6b 100%);
|
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 50%, #ff6b6b 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 28rpx 80rpx;
|
line-height: 1;
|
||||||
|
padding: 24rpx 72rpx;
|
||||||
border-radius: 60rpx;
|
border-radius: 60rpx;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0 10rpx 40rpx rgba(196, 30, 58, 0.5),
|
box-shadow: 0 10rpx 40rpx rgba(196, 30, 58, 0.5),
|
||||||
0 0 60rpx rgba(255, 107, 107, 0.3);
|
0 0 50rpx rgba(255, 107, 107, 0.25);
|
||||||
transition: all 0.3s;
|
}
|
||||||
|
|
||||||
|
.btn-wish::after {
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-wish:active {
|
.btn-wish:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
box-shadow: 0 5rpx 20rpx rgba(196, 30, 58, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-wish .icon {
|
.btn-wish .icon {
|
||||||
font-size: 44rpx;
|
font-size: 36rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹窗 */
|
.btn-wish .btn-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 便签详情弹窗 */
|
||||||
|
.detail-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
position: relative;
|
||||||
|
width: 560rpx;
|
||||||
|
padding: 64rpx 48rpx 40rpx;
|
||||||
|
background: linear-gradient(160deg, #fff8e7 0%, #ffedc8 100%);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-shadow: 0 20rpx 80rpx rgba(0, 0, 0, 0.5);
|
||||||
|
transform: rotate(-1.5deg);
|
||||||
|
animation: tag-drop 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶级便签:金色卡片,与普通便签区分 */
|
||||||
|
.note-card.top-note {
|
||||||
|
background: linear-gradient(160deg, #fff3c4 0%, #ffd97a 100%);
|
||||||
|
border: 2rpx solid rgba(255, 200, 60, 0.8);
|
||||||
|
box-shadow: 0 20rpx 80rpx rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 60rpx rgba(255, 215, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-string {
|
||||||
|
position: absolute;
|
||||||
|
top: -48rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 2rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: linear-gradient(to bottom, transparent, #b8860b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-knot {
|
||||||
|
position: absolute;
|
||||||
|
top: -10rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #8b5a00;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 1rpx solid rgba(180, 120, 0, 0.4);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
color: #3a2a1a;
|
||||||
|
line-height: 1.8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-author {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(58, 42, 26, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-date {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(58, 42, 26, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 许愿弹窗 */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -351,14 +452,12 @@
|
|||||||
border: 2rpx solid rgba(255, 255, 255, 0.2);
|
border: 2rpx solid rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-item.active {
|
.type-item.active {
|
||||||
border-color: #ffd700;
|
border-color: #ffd700;
|
||||||
background: rgba(255, 215, 0, 0.1);
|
background: rgba(255, 215, 0, 0.1);
|
||||||
box-shadow: 0 0 30rpx rgba(255, 215, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-name {
|
.type-name {
|
||||||
@@ -387,10 +486,6 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wish-input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-count {
|
.input-count {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -403,7 +498,7 @@
|
|||||||
margin-top: 40rpx;
|
margin-top: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.product-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
@@ -423,7 +518,6 @@
|
|||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.15);
|
border: 2rpx solid rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
transition: all 0.3s;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +558,7 @@
|
|||||||
border-radius: 60rpx;
|
border-radius: 60rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
@@ -476,7 +571,6 @@
|
|||||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
|
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0 8rpx 30rpx rgba(196, 30, 58, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画 */
|
/* 动画 */
|
||||||
@@ -500,30 +594,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes drift {
|
|
||||||
0% {
|
|
||||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate3d(-40px, 120vh, 0) rotate(360deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tag-drop {
|
@keyframes tag-drop {
|
||||||
0% {
|
0% {
|
||||||
transform: translateY(-24px) rotate(-8deg);
|
transform: translateY(-40rpx) rotate(-8deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
60% {
|
60% {
|
||||||
transform: translateY(4px) rotate(3deg);
|
transform: translateY(8rpx) rotate(3deg);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
@@ -531,3 +608,13 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹幕:从屏幕右侧飘到左侧。每条只播一次,由 JS 控制一行一条排队出现。 */
|
||||||
|
@keyframes danmaku {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100vw);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"bundle": false,
|
"bundle": false,
|
||||||
"userConfirmedBundleSwitch": false,
|
"userConfirmedBundleSwitch": false,
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"scopeDataCheck": false,
|
"scopeDataCheck": false,
|
||||||
"coverView": true,
|
"coverView": true,
|
||||||
"es6": true,
|
"es6": true,
|
||||||
@@ -47,5 +47,6 @@
|
|||||||
"editorSetting": {
|
"editorSetting": {
|
||||||
"tabIndent": "insertSpaces",
|
"tabIndent": "insertSpaces",
|
||||||
"tabSize": 2
|
"tabSize": 2
|
||||||
}
|
},
|
||||||
|
"isGameTourist": false
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// 生辰档案管理工具
|
||||||
|
// 本地存储优先;已登录(存在 token)时与服务端双向同步
|
||||||
|
const { request } = require('./request.js');
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lunar-birth-profiles';
|
||||||
|
// 每个用户可免费绑定的生辰数量上限
|
||||||
|
const MAX_PROFILES = 3;
|
||||||
|
|
||||||
|
/** 读取本地生辰档案列表 */
|
||||||
|
function getProfiles() {
|
||||||
|
return wx.getStorageSync(STORAGE_KEY) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存本地生辰档案列表 */
|
||||||
|
function saveProfiles(profiles) {
|
||||||
|
wx.setStorageSync(STORAGE_KEY, profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前激活的档案(第一个为默认) */
|
||||||
|
function getActiveProfile() {
|
||||||
|
const profiles = getProfiles();
|
||||||
|
if (profiles.length === 0) return null;
|
||||||
|
const activeId = wx.getStorageSync('lunar-birth-active-id');
|
||||||
|
return profiles.find(p => p.id === activeId) || profiles[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置激活档案 */
|
||||||
|
function setActiveProfile(id) {
|
||||||
|
wx.setStorageSync('lunar-birth-active-id', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增生辰档案(本地),超出上限或重复时返回错误信息 */
|
||||||
|
function addProfile(profile) {
|
||||||
|
const profiles = getProfiles();
|
||||||
|
if (profiles.length >= MAX_PROFILES) {
|
||||||
|
return { ok: false, msg: `最多可免费绑定 ${MAX_PROFILES} 个生辰` };
|
||||||
|
}
|
||||||
|
const dup = profiles.find(p => p.birthday === profile.birthday && p.birthTime === profile.birthTime);
|
||||||
|
if (dup) {
|
||||||
|
return { ok: false, msg: '该生辰已绑定,请勿重复添加' };
|
||||||
|
}
|
||||||
|
const item = {
|
||||||
|
id: `local-${Date.now()}`,
|
||||||
|
name: profile.name || '未命名',
|
||||||
|
gender: profile.gender || 'male',
|
||||||
|
birthday: profile.birthday,
|
||||||
|
birthTime: profile.birthTime,
|
||||||
|
serverId: 0,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
profiles.push(item);
|
||||||
|
saveProfiles(profiles);
|
||||||
|
if (!getActiveProfile()) setActiveProfile(item.id);
|
||||||
|
return { ok: true, profile: item };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除生辰档案(本地) */
|
||||||
|
function removeProfile(id) {
|
||||||
|
const profiles = getProfiles().filter(p => p.id !== id);
|
||||||
|
saveProfiles(profiles);
|
||||||
|
if (wx.getStorageSync('lunar-birth-active-id') === id) {
|
||||||
|
setActiveProfile(profiles.length > 0 ? profiles[0].id : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已登录(存在有效 token) */
|
||||||
|
function isLoggedIn() {
|
||||||
|
return !!wx.getStorageSync('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送单个档案到服务端(静默失败,不影响本地) */
|
||||||
|
function pushProfileToServer(profile) {
|
||||||
|
if (!isLoggedIn()) return Promise.resolve(null);
|
||||||
|
return request({
|
||||||
|
url: '/api/user/birth-profiles',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
name: profile.name,
|
||||||
|
gender: profile.gender === 'female' ? 2 : 1,
|
||||||
|
birthday: profile.birthday,
|
||||||
|
birthTime: profile.birthTime
|
||||||
|
}
|
||||||
|
}).then(res => {
|
||||||
|
if (res && res.code === 0 && res.data && res.data.profile) {
|
||||||
|
// 回填 serverId
|
||||||
|
const profiles = getProfiles();
|
||||||
|
const target = profiles.find(p => p.id === profile.id);
|
||||||
|
if (target) {
|
||||||
|
target.serverId = res.data.profile.id;
|
||||||
|
saveProfiles(profiles);
|
||||||
|
}
|
||||||
|
return res.data.profile;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除服务端档案(静默失败) */
|
||||||
|
function removeProfileFromServer(profile) {
|
||||||
|
if (!isLoggedIn() || !profile.serverId) return Promise.resolve();
|
||||||
|
return request({
|
||||||
|
url: `/api/user/birth-profiles/${profile.serverId}`,
|
||||||
|
method: 'DELETE'
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从服务端拉取档案并与本地合并(以服务端为准) */
|
||||||
|
function syncFromServer() {
|
||||||
|
if (!isLoggedIn()) return Promise.resolve(getProfiles());
|
||||||
|
return request({ url: '/api/user/birth-profiles' })
|
||||||
|
.then(res => {
|
||||||
|
if (!res || res.code !== 0 || !Array.isArray(res.data && res.data.profiles)) {
|
||||||
|
return getProfiles();
|
||||||
|
}
|
||||||
|
const serverList = res.data.profiles.map(sp => ({
|
||||||
|
id: `server-${sp.id}`,
|
||||||
|
serverId: sp.id,
|
||||||
|
name: sp.name || '未命名',
|
||||||
|
gender: sp.gender === 2 ? 'female' : 'male',
|
||||||
|
birthday: sp.birthday,
|
||||||
|
birthTime: sp.birthTime,
|
||||||
|
createdAt: sp.createdAt ? new Date(sp.createdAt).getTime() : 0
|
||||||
|
}));
|
||||||
|
// 本地尚未成功推送的档案保留,按 (birthday+birthTime) 去重
|
||||||
|
const merged = [...serverList];
|
||||||
|
getProfiles().forEach(lp => {
|
||||||
|
const exists = merged.some(m => m.birthday === lp.birthday && m.birthTime === lp.birthTime);
|
||||||
|
if (!exists) merged.push(lp);
|
||||||
|
});
|
||||||
|
saveProfiles(merged.slice(0, MAX_PROFILES));
|
||||||
|
return getProfiles();
|
||||||
|
})
|
||||||
|
.catch(() => getProfiles());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MAX_PROFILES,
|
||||||
|
getProfiles,
|
||||||
|
getActiveProfile,
|
||||||
|
setActiveProfile,
|
||||||
|
addProfile,
|
||||||
|
removeProfile,
|
||||||
|
isLoggedIn,
|
||||||
|
pushProfileToServer,
|
||||||
|
removeProfileFromServer,
|
||||||
|
syncFromServer
|
||||||
|
};
|
||||||
@@ -1228,8 +1228,9 @@ function daysBetween(fromY, fromM, fromD, toY, toM, toD) {
|
|||||||
|
|
||||||
/** 获取即将到来的 N 个节日(含基准日之后的)
|
/** 获取即将到来的 N 个节日(含基准日之后的)
|
||||||
* baseYear/baseMonth/baseDay 可选,默认今天
|
* baseYear/baseMonth/baseDay 可选,默认今天
|
||||||
|
* includeBuddhist 为 true 时,把佛教节日也并入一起按倒计时排序
|
||||||
*/
|
*/
|
||||||
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay, includeBuddhist = false) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const y = baseYear || now.getFullYear();
|
const y = baseYear || now.getFullYear();
|
||||||
const m = baseMonth || (now.getMonth() + 1);
|
const m = baseMonth || (now.getMonth() + 1);
|
||||||
@@ -1245,6 +1246,7 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
|||||||
if (diff >= 0) {
|
if (diff >= 0) {
|
||||||
list.push({
|
list.push({
|
||||||
name: fest.name,
|
name: fest.name,
|
||||||
|
type: fest.type,
|
||||||
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
|
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
|
||||||
solarYear: solar.year,
|
solarYear: solar.year,
|
||||||
solarMonth: solar.month,
|
solarMonth: solar.month,
|
||||||
@@ -1256,10 +1258,58 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 佛历节日(开启后并入,统一按倒计时排序)
|
||||||
|
if (includeBuddhist) {
|
||||||
|
for (const [key, name] of Object.entries(BUDDHIST_FESTIVALS)) {
|
||||||
|
const [lm, ld] = key.split('-').map(Number);
|
||||||
|
for (const tryYear of [y, y + 1]) {
|
||||||
|
const solar = lunarFestivalToSolar(tryYear, lm, ld);
|
||||||
|
if (!solar) continue;
|
||||||
|
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
|
||||||
|
if (diff >= 0) {
|
||||||
|
list.push({
|
||||||
|
name,
|
||||||
|
type: 'buddhist',
|
||||||
|
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
|
||||||
|
solarYear: solar.year,
|
||||||
|
solarMonth: solar.month,
|
||||||
|
solarDay: solar.day,
|
||||||
|
daysLeft: diff
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list.sort((a, b) => a.daysLeft - b.daysLeft);
|
list.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||||
return list.slice(0, count);
|
return list.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取今年所有节日(农历+公历) */
|
||||||
|
function getYearFestivals(year) {
|
||||||
|
const list = [];
|
||||||
|
for (const fest of FESTIVAL_QUICK_SEARCH) {
|
||||||
|
const solar = getFestivalSolarDate(year, fest);
|
||||||
|
if (solar) {
|
||||||
|
list.push({
|
||||||
|
name: fest.name,
|
||||||
|
type: fest.type,
|
||||||
|
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
|
||||||
|
solarYear: solar.year,
|
||||||
|
solarMonth: solar.month,
|
||||||
|
solarDay: solar.day
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按日期排序
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.solarMonth !== b.solarMonth) return a.solarMonth - b.solarMonth;
|
||||||
|
return a.solarDay - b.solarDay;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取下一个佛教节日及倒计时(基准日可选,默认今天) */
|
/** 获取下一个佛教节日及倒计时(基准日可选,默认今天) */
|
||||||
function getNextBuddhistFestival(baseYear, baseMonth, baseDay) {
|
function getNextBuddhistFestival(baseYear, baseMonth, baseDay) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -1428,6 +1478,7 @@ module.exports = {
|
|||||||
getWeekDay,
|
getWeekDay,
|
||||||
getFestivalSolarDate,
|
getFestivalSolarDate,
|
||||||
getUpcomingFestivals,
|
getUpcomingFestivals,
|
||||||
|
getYearFestivals,
|
||||||
getNextBuddhistFestival,
|
getNextBuddhistFestival,
|
||||||
daysBetween,
|
daysBetween,
|
||||||
analyzeShensha,
|
analyzeShensha,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// 网络请求封装
|
// 网络请求封装
|
||||||
// 按小程序运行环境自动切换后端地址:
|
// 按小程序运行环境自动切换后端地址:
|
||||||
// - develop(开发者工具):本地开发服务
|
// - develop(开发者工具):连线上数据(本地 8080 被其他服务占用,且 localhost 受域名校验限制)
|
||||||
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
|
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
|
||||||
const API_HOSTS = {
|
const API_HOSTS = {
|
||||||
develop: 'http://localhost:8080',
|
develop: 'https://lunar.neatcn.com',
|
||||||
trial: 'https://lunar.neatcn.com',
|
trial: 'https://lunar.neatcn.com',
|
||||||
release: 'https://lunar.neatcn.com',
|
release: 'https://lunar.neatcn.com',
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-1
@@ -7,6 +7,7 @@
|
|||||||
- **用户模块** — 微信登录、资料修改、授权管理
|
- **用户模块** — 微信登录、资料修改、授权管理
|
||||||
- **支付模块** — 微信支付集成、订单管理
|
- **支付模块** — 微信支付集成、订单管理
|
||||||
- **许愿树** — 许愿发布、付费许愿、机器人自动许愿
|
- **许愿树** — 许愿发布、付费许愿、机器人自动许愿
|
||||||
|
- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新
|
||||||
- **管理后台** — 基于 Inertia.js 的前后端分离管理界面
|
- **管理后台** — 基于 Inertia.js 的前后端分离管理界面
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -87,9 +88,26 @@ server/
|
|||||||
- `GET /api/wish/list` — 许愿列表
|
- `GET /api/wish/list` — 许愿列表
|
||||||
- `DELETE /api/wish/:id` — 删除许愿
|
- `DELETE /api/wish/:id` — 删除许愿
|
||||||
|
|
||||||
|
### 百科接口
|
||||||
|
|
||||||
|
- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤)
|
||||||
|
- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗)
|
||||||
|
|
||||||
|
### 管理后台接口(/admin,需管理员认证)
|
||||||
|
|
||||||
|
- `GET/POST /admin/api/wiki/entries` — 百科条目列表/新增
|
||||||
|
- `PUT/DELETE /admin/api/wiki/entries/:id` — 更新/删除条目
|
||||||
|
- `GET /admin/api/wiki/sync-status` — 维基同步状态
|
||||||
|
- `POST /admin/api/wiki/sync` — 手动触发全量同步
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
参考 `.env.local` 文件配置。
|
参考 `.env.local` 文件配置。维基同步相关:
|
||||||
|
|
||||||
|
- `WIKI_SYNC_INTERVAL_DAYS` — 自动同步间隔(天),默认 7
|
||||||
|
- `WIKI_SYNC_HOUR` — 自动同步执行的小时点(0-23),默认 4
|
||||||
|
|
||||||
|
同步策略:服务启动时若数据缺失自动补齐;之后每天在指定小时点检查,距上次成功超过间隔天数才全量刷新(共 366 页,只抓中文版)。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gouki/lunar-server/internal/config"
|
"github.com/gouki/lunar-server/internal/config"
|
||||||
@@ -19,6 +22,11 @@ var (
|
|||||||
CommitSha = "unknown"
|
CommitSha = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
|
||||||
|
//
|
||||||
|
//go:embed assets/images
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
|
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
|
||||||
_ = godotenv.Load(".env.local", "../.env.local", ".env")
|
_ = godotenv.Load(".env.local", "../.env.local", ".env")
|
||||||
@@ -41,6 +49,14 @@ func main() {
|
|||||||
wishService := service.NewWishService()
|
wishService := service.NewWishService()
|
||||||
wishService.StartRobotWishJob()
|
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" {
|
if cfg.Server.Env == "production" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -57,6 +73,11 @@ func main() {
|
|||||||
r.StaticFile("/", "./web/index.html")
|
r.StaticFile("/", "./web/index.html")
|
||||||
r.LoadHTMLFiles("./web/index.html")
|
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 路由
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
@@ -71,6 +92,10 @@ func main() {
|
|||||||
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
|
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
|
||||||
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
|
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
|
||||||
user.POST("/auth", handler.WechatAuth)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支付相关
|
// 支付相关
|
||||||
@@ -101,6 +126,13 @@ func main() {
|
|||||||
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
|
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
|
||||||
wish.GET("/products", handler.GetWishProducts)
|
wish.GET("/products", handler.GetWishProducts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 百科/历史上的今天(无需认证)
|
||||||
|
wiki := api.Group("/wiki")
|
||||||
|
{
|
||||||
|
wiki.GET("/entries", handler.GetWikiEntries)
|
||||||
|
wiki.GET("/on-this-day", handler.GetOnThisDay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理后台路由(Inertia.js)
|
// 管理后台路由(Inertia.js)
|
||||||
@@ -117,7 +149,19 @@ func main() {
|
|||||||
authed.GET("/users", handler.AdminUsers)
|
authed.GET("/users", handler.AdminUsers)
|
||||||
authed.GET("/orders", handler.AdminOrders)
|
authed.GET("/orders", handler.AdminOrders)
|
||||||
authed.GET("/wishes", handler.AdminWishes)
|
authed.GET("/wishes", handler.AdminWishes)
|
||||||
|
authed.GET("/wiki", handler.AdminWiki)
|
||||||
authed.GET("/settings", handler.AdminSettings)
|
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/wiki/term-sync-status", handler.AdminTermSyncStatus)
|
||||||
|
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -4,7 +4,11 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -35,11 +39,16 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
golang.org/x/crypto v0.17.0 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
gorm.io/gorm v1.31.2 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-3
@@ -9,12 +9,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -33,6 +39,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
|||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -48,6 +58,8 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
|||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -57,6 +69,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
|||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -83,12 +98,11 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
@@ -99,6 +113,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -11,6 +12,7 @@ type Config struct {
|
|||||||
JWT JWTConfig
|
JWT JWTConfig
|
||||||
Wechat WechatConfig
|
Wechat WechatConfig
|
||||||
Admin AdminConfig
|
Admin AdminConfig
|
||||||
|
Wiki WikiConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -20,6 +22,7 @@ type ServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
|
Type string
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
User string
|
User string
|
||||||
@@ -51,6 +54,14 @@ type AdminConfig struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WikiConfig 百科/历史上的今天(维基百科同步)配置
|
||||||
|
type WikiConfig struct {
|
||||||
|
// SyncIntervalDays 同步间隔(天),距上次成功同步超过该间隔才会自动执行
|
||||||
|
SyncIntervalDays int
|
||||||
|
// SyncHour 自动同步执行的小时点(0-23)
|
||||||
|
SyncHour int
|
||||||
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
@@ -59,6 +70,7 @@ func Load() *Config {
|
|||||||
CORSOrigins: getEnv("CORS_ORIGINS", ""),
|
CORSOrigins: getEnv("CORS_ORIGINS", ""),
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
|
Type: getEnv("DB_TYPE", "mysql"),
|
||||||
Host: getEnv("DB_HOST", "localhost"),
|
Host: getEnv("DB_HOST", "localhost"),
|
||||||
Port: getEnv("DB_PORT", "3306"),
|
Port: getEnv("DB_PORT", "3306"),
|
||||||
User: getEnv("DB_USER", "root"),
|
User: getEnv("DB_USER", "root"),
|
||||||
@@ -83,9 +95,22 @@ func Load() *Config {
|
|||||||
Admin: AdminConfig{
|
Admin: AdminConfig{
|
||||||
Password: getEnv("ADMIN_PASSWORD", ""),
|
Password: getEnv("ADMIN_PASSWORD", ""),
|
||||||
},
|
},
|
||||||
|
Wiki: WikiConfig{
|
||||||
|
SyncIntervalDays: getEnvInt("WIKI_SYNC_INTERVAL_DAYS", 7),
|
||||||
|
SyncHour: getEnvInt("WIKI_SYNC_HOUR", 4),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, defaultValue int) int {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
if n, err := strconv.Atoi(value); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"github.com/gouki/lunar-server/internal/model"
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
@@ -13,6 +14,17 @@ var DB *gorm.DB
|
|||||||
|
|
||||||
// InitDB 初始化数据库连接
|
// InitDB 初始化数据库连接
|
||||||
func InitDB(cfg *Config) error {
|
func InitDB(cfg *Config) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch cfg.Database.Type {
|
||||||
|
case "sqlite", "sqlite3":
|
||||||
|
// DB_NAME 作为 SQLite 文件路径,例如 /app/data/lunar.db
|
||||||
|
dbPath := cfg.Database.Name
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "lunar.db"
|
||||||
|
}
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
default:
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
cfg.Database.User,
|
cfg.Database.User,
|
||||||
cfg.Database.Password,
|
cfg.Database.Password,
|
||||||
@@ -20,9 +32,9 @@ func InitDB(cfg *Config) error {
|
|||||||
cfg.Database.Port,
|
cfg.Database.Port,
|
||||||
cfg.Database.Name,
|
cfg.Database.Name,
|
||||||
)
|
)
|
||||||
|
|
||||||
var err error
|
|
||||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect database: %w", err)
|
return fmt.Errorf("failed to connect database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -41,21 +53,38 @@ func autoMigrate() error {
|
|||||||
if err := DB.AutoMigrate(
|
if err := DB.AutoMigrate(
|
||||||
&model.User{},
|
&model.User{},
|
||||||
&model.UserProfile{},
|
&model.UserProfile{},
|
||||||
|
&model.BirthProfile{},
|
||||||
&model.Order{},
|
&model.Order{},
|
||||||
&model.OrderItem{},
|
&model.OrderItem{},
|
||||||
&model.WishTree{},
|
&model.WishTree{},
|
||||||
&model.Wish{},
|
&model.Wish{},
|
||||||
&model.WishProduct{},
|
&model.WishProduct{},
|
||||||
|
&model.WikiEntry{},
|
||||||
|
&model.WikiOnThisDay{},
|
||||||
|
&model.WikiSyncLog{},
|
||||||
|
&model.BaikeTermSyncLog{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化默认数据
|
// 初始化默认数据
|
||||||
return seedDefaultData()
|
if err := seedDefaultData(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化百科种子数据(详细版,可后台补充/修改)
|
||||||
|
return seedWikiEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDefaultData 初始化默认数据
|
// seedDefaultData 初始化默认数据
|
||||||
func seedDefaultData() error {
|
func seedDefaultData() error {
|
||||||
|
// 补充已有许愿树的背景图(新增字段后老数据为空)
|
||||||
|
if err := DB.Model(&model.WishTree{}).
|
||||||
|
Where("image = '' OR image IS NULL").
|
||||||
|
Update("image", "/images/trees/tree-1.jpg").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否已有许愿树数据
|
// 检查是否已有许愿树数据
|
||||||
var count int64
|
var count int64
|
||||||
DB.Model(&model.WishTree{}).Count(&count)
|
DB.Model(&model.WishTree{}).Count(&count)
|
||||||
@@ -65,9 +94,9 @@ func seedDefaultData() error {
|
|||||||
|
|
||||||
// 插入默认许愿树
|
// 插入默认许愿树
|
||||||
trees := []model.WishTree{
|
trees := []model.WishTree{
|
||||||
{Name: "祈福树", Description: "许下美好愿望,祈福平安顺遂", Type: "pine", MaxWishes: 100, Sort: 0, Status: 1},
|
{Name: "祈福树", Description: "许下美好愿望,祈福平安顺遂", Type: "pine", Image: "/images/trees/tree-1.jpg", MaxWishes: 100, Sort: 0, Status: 1},
|
||||||
{Name: "姻缘树", Description: "祈求姻缘美满,爱情甜蜜", Type: "sakura", MaxWishes: 50, Sort: 1, Status: 1},
|
{Name: "姻缘树", Description: "祈求姻缘美满,爱情甜蜜", Type: "sakura", Image: "/images/trees/tree-1.jpg", MaxWishes: 50, Sort: 1, Status: 1},
|
||||||
{Name: "事业树", Description: "祈愿事业顺利,步步高升", Type: "bamboo", MaxWishes: 80, Sort: 2, Status: 1},
|
{Name: "事业树", Description: "祈愿事业顺利,步步高升", Type: "bamboo", Image: "/images/trees/tree-1.jpg", MaxWishes: 80, Sort: 2, Status: 1},
|
||||||
}
|
}
|
||||||
if err := DB.Create(&trees).Error; err != nil {
|
if err := DB.Create(&trees).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedWikiEntries 初始化百科种子数据(仅表为空时写入,后续可在管理后台补充/修改)
|
||||||
|
func seedWikiEntries() error {
|
||||||
|
var count int64
|
||||||
|
DB.Model(&model.WikiEntry{}).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := defaultWikiEntries()
|
||||||
|
if err := DB.Create(&entries).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Wiki entries seeded: %d", len(entries))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedEntry struct {
|
||||||
|
Category string
|
||||||
|
Title string
|
||||||
|
Brief string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultWikiEntries 详细版百科内容
|
||||||
|
func defaultWikiEntries() []model.WikiEntry {
|
||||||
|
sort := 0
|
||||||
|
build := func(list []seedEntry) []model.WikiEntry {
|
||||||
|
out := make([]model.WikiEntry, 0, len(list))
|
||||||
|
for _, e := range list {
|
||||||
|
sort++
|
||||||
|
out = append(out, model.WikiEntry{
|
||||||
|
Category: e.Category,
|
||||||
|
Title: e.Title,
|
||||||
|
Brief: e.Brief,
|
||||||
|
Content: e.Content,
|
||||||
|
Sort: sort,
|
||||||
|
Status: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
all := seedSolarTerms()
|
||||||
|
all = append(all, seedLunarFestivals()...)
|
||||||
|
all = append(all, seedSolarFestivals()...)
|
||||||
|
all = append(all, seedGanZhi()...)
|
||||||
|
all = append(all, seedTermExplains()...)
|
||||||
|
return build(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 二十四节气 =====
|
||||||
|
func seedSolarTerms() []seedEntry {
|
||||||
|
return []seedEntry{
|
||||||
|
{"term", "立春", "春季开始,万物复苏", "立春为二十四节气之首,一般在公历2月3日至5日交节,标志着冬季结束、春季开始。此时东风解冻、蛰虫始振,民间有迎春、打春牛、咬春(吃春饼、萝卜)等习俗,寓意辞旧迎新、祈福纳吉。"},
|
||||||
|
{"term", "雨水", "降雨开始增多,气温回升", "雨水节气意味着降水渐增、冰雪消融,春雨滋润大地,是备耕春耕的重要时节。民间有回娘家、拉保保(拜干爹)等习俗。此时乍暖还寒,养生宜防湿健脾、适当春捂。"},
|
||||||
|
{"term", "惊蛰", "春雷惊醒蛰伏的动物", "惊蛰时春雷乍动,惊醒蛰伏冬眠的昆虫,气温迅速回升,春耕大忙开始。古有惊蛰日祭白虎、蒙鼓皮的习俗,民间也讲究吃梨以润肺防燥。桃始华、仓庚鸣,春意渐浓。"},
|
||||||
|
{"term", "春分", "昼夜平分,春季过半", "春分日太阳直射赤道,昼夜等长,此后阳光直射点北移,北半球昼长夜短。春分有竖蛋、吃春菜、送春牛图等趣味习俗。此时农谚云春分麦起身,肥水要紧跟。"},
|
||||||
|
{"term", "清明", "天气清朗明净,草木繁茂", "清明既是节气也是传统节日,有踏青、扫墓祭祖、插柳、放风筝、荡秋千等习俗。此时气温升高、雨量增多,正是春耕春种大好时机,谚语清明前后,种瓜点豆。"},
|
||||||
|
{"term", "谷雨", "雨生百谷,播种移苗时节", "谷雨是春季最后一个节气,意为雨生百谷,降雨量渐增有利于谷类作物生长,是播种移苗、埯瓜点豆的最佳时节。民间有赏牡丹、饮谷雨茶、祭仓颉、走谷雨等习俗。"},
|
||||||
|
{"term", "立夏", "夏季开始,万物繁茂", "立夏表示告别春天、夏季开始,万物进入旺盛生长期。民间有立夏秤人、吃立夏蛋、尝三新(樱桃、青梅、新麦)等习俗。养生宜养心安神、清淡饮食。"},
|
||||||
|
{"term", "小满", "麦类等夏熟作物籽粒开始饱满", "小满意为夏熟作物的籽粒开始灌浆饱满但尚未成熟。此时气温进一步升高、降水增多,民间有祭车神、祭蚕神、食苦菜等习俗。小满也寓意人生小满即安的处世哲学。"},
|
||||||
|
{"term", "芒种", "有芒作物成熟收割,忙于夏播", "芒种指有芒的麦子快收、有芒的稻子可种,是农事最繁忙的时节,又称忙种。民间有送花神、安苗、煮青梅等习俗。此时天气炎热,宜防暑补水、晚睡早起。"},
|
||||||
|
{"term", "夏至", "白昼最长,盛夏来临", "夏至日太阳直射北回归线,北半球白昼最长。古有夏至祭地、吃面的习俗,取冬至饺子夏至面之意。夏至后进入酷暑,民间开始数伏,养生宜清心寡欲、清淡饮食。"},
|
||||||
|
{"term", "小暑", "天气开始炎热,尚未到极点", "小暑意为天气开始炎热但还没到最热,民间有食新、晒伏(晾晒衣物去潮)、吃藕、吃饺子等习俗。小暑前后进入三伏天,要注意防暑降温、饮食卫生。"},
|
||||||
|
{"term", "大暑", "一年中最炎热的时期", "大暑是一年中最热的节气,湿热交蒸达到顶点,也是农作物生长最快、雷暴台风最多的时期。民间有饮伏茶、晒伏姜、烧伏香、送大暑船等习俗,养生宜防中暑、忌贪凉。"},
|
||||||
|
{"term", "立秋", "秋季开始,暑去凉来", "立秋标志着孟秋开始,暑去凉来。民间有贴秋膘(吃肉进补)、啃秋(吃西瓜)、晒秋等习俗。立秋后早晚渐凉,但常有秋老虎高温,养生宜滋阴润肺、防燥护津。"},
|
||||||
|
{"term", "处暑", "暑气至此而止,秋意渐浓", "处暑即出暑,表示炎热暑天结束,秋高气爽、云淡风轻。民间有放河灯、开渔节、吃鸭子的习俗,俗话说处暑送鸭,无病各家。此时秋意渐浓,是出游赏秋的好时节。"},
|
||||||
|
{"term", "白露", "天气转凉,清晨露水凝白", "白露是反映气温变化的重要节气,昼夜温差加大,清晨草木上凝结出白色露水。民间有收清露、饮白露茶、酿白露米酒、祭禹王等习俗。养生宜防凉润燥,俗语白露身不露。"},
|
||||||
|
{"term", "秋分", "昼夜平分,秋季过半", "秋分日再次昼夜等长,此后夜渐长昼渐短。秋分曾是传统祭月节,中秋节即源于秋分祭月。民间有竖蛋、吃秋菜、粘雀子嘴等习俗。2018年起秋分被设立为中国农民丰收节。"},
|
||||||
|
{"term", "寒露", "露水更凉,将要凝结", "寒露时节露水更凉、将欲凝霜,是气候从凉爽到寒冷的过渡节点。民间有登高赏菊、饮菊花酒、吃芝麻的习俗,重阳节常在其前后。养生宜足部保暖、防秋燥。"},
|
||||||
|
{"term", "霜降", "天气渐冷,开始降霜", "霜降是秋季最后一个节气,意味着天气渐冷、初霜出现,是秋到冬的过渡。民间有赏菊、吃柿子、进补的习俗,俗语补冬不如补霜降。此时应注意保暖、防秋郁。"},
|
||||||
|
{"term", "立冬", "冬季开始,万物收藏", "立冬表示冬季开始,万物进入休养收藏状态。民间有迎冬、补冬(北方吃饺子、南方食羊肉炉)、酿黄酒等习俗。养生宜早卧晚起、避寒就温、滋阴潜阳。"},
|
||||||
|
{"term", "小雪", "开始降雪,但雪量尚小", "小雪是反映降雪的节气,意味着降雪开始、雪量不大。民间有腌腊肉、晒鱼干、吃糍粑、酿小雪酒的习俗,冬腊风腌蓄以御冬。此时宜温补益肾、调节情志。"},
|
||||||
|
{"term", "大雪", "降雪增多,天寒地冻", "大雪意味着降雪概率增大、天气更冷,瑞雪兆丰年。民间有观赏封河、腌肉、进补的习俗,俗语冬天进补,开春打虎。此时宜保暖防寒、适当食补。"},
|
||||||
|
{"term", "冬至", "白昼最短,数九寒天开始", "冬至日北半球白昼最短,此后进入数九寒天。冬至大如年,北方吃饺子、南方吃汤圆,还有祭祖、贺冬、画九九消寒图等习俗。冬至也是阴阳转换的关键节点,养生宜静养藏阳。"},
|
||||||
|
{"term", "小寒", "天气寒冷,尚未到极点", "小寒标志着进入一年中最寒冷的日子,常与三九重合,民谚三九四九冰上走。民间有吃腊八粥、探梅、冰嬉的习俗。养生宜重防寒保暖、温补脾肾。"},
|
||||||
|
{"term", "大寒", "一年中最寒冷的时期", "大寒是二十四节气中最后一个节气,天气寒冷到极致,也意味着冬去春来、新旧交替。此时正值岁末,民间有尾牙祭、扫尘洁物、准备年货的习俗,迎接新春。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 农历节日 =====
|
||||||
|
func seedLunarFestivals() []seedEntry {
|
||||||
|
return []seedEntry{
|
||||||
|
{"lunarFest", "春节", "农历正月初一,百节之首", "春节是中国最隆重热闹的传统节日,俗称过年,源于上古岁首祈岁祭祀。除夕守岁、贴春联、放爆竹、拜年、发压岁钱,正月初一起走亲访友、舞龙舞狮,处处辞旧迎新、祈福纳祥。"},
|
||||||
|
{"lunarFest", "元宵节", "农历正月十五,赏灯吃元宵", "元宵节又称上元节、灯节,是春节之后的第一个重要节日。正月十五赏灯会、猜灯谜、吃元宵(汤圆)、舞龙灯,寓意团圆美满。古代元宵节也是青年男女相识的浪漫之夜。"},
|
||||||
|
{"lunarFest", "龙抬头", "农历二月初二,剃龙头迎新", "二月初二龙抬头,又称春耕节、农事节,象征春回大地、万物复苏,民间认为龙王开始降雨。习俗有剃龙头(理发)、吃龙食(面条称龙须、饺子称龙耳)、引钱龙,祈愿一年好运。"},
|
||||||
|
{"lunarFest", "上巳节", "农历三月初三,临水祓禊", "上巳节俗称三月三,古时人们于水边沐浴祓禊、驱除不祥,后演变为春游踏青、曲水流觞。王羲之《兰亭集序》记载的正是上巳雅集。今壮族、黎族等仍以三月三为重要歌节。"},
|
||||||
|
{"lunarFest", "端午节", "农历五月初五,龙舟粽子", "端午节源于自然天象崇拜,后因纪念屈原而广为流传,是中国首个入选世界非遗的节日。习俗有赛龙舟、吃粽子、挂艾草菖蒲、佩香囊、系五彩绳、饮雄黄酒,驱邪避疫、祈福安康。"},
|
||||||
|
{"lunarFest", "七夕节", "农历七月初七,中国情人节", "七夕节源于牛郎织女的爱情传说,又称乞巧节、女儿节。旧时女子于此夜穿针乞巧、拜织女,祈求心灵手巧、姻缘美满。2006年列入国家级非物质文化遗产,今被视为中国情人节。"},
|
||||||
|
{"lunarFest", "中元节", "农历七月十五,祭祖放河灯", "中元节俗称七月半、鬼节,是道教中元、佛教盂兰盆节与民间祭祖的融合。人们祭祀祖先、焚纸钱、放河灯,缅怀先人、普度亡灵,体现慎终追远的传统文化精神。"},
|
||||||
|
{"lunarFest", "中秋节", "农历八月十五,赏月团圆", "中秋节以月圆兆人团圆,是寄托思乡之情、祈盼丰收幸福的传统节日。习俗有赏月、吃月饼、饮桂花酒、玩花灯、观潮,嫦娥奔月、吴刚伐桂的传说为其增添浪漫色彩。"},
|
||||||
|
{"lunarFest", "重阳节", "农历九月初九,登高敬老", "重阳节因日月并阳、两九相重而得名,又称登高节、老人节。习俗有登高远眺、赏菊饮酒、插茱萸、吃重阳糕。1989年起被定为敬老节,倡导尊老敬老之风。"},
|
||||||
|
{"lunarFest", "寒衣节", "农历十月初一,送寒衣祭祖", "寒衣节又称祭祖节、十月朝,与清明、中元并称三大鬼节。入冬之际,人们祭祀祖先、焚烧纸衣,为先人送去御寒冬衣,寄托对逝者的思念与关怀。"},
|
||||||
|
{"lunarFest", "腊八节", "农历腊月初八,喝腊八粥", "腊八节源于佛教纪念释迦牟尼成道,民间喝腊八粥、腌腊八蒜、做腊八豆腐。粥以米、豆、干果等八样食材熬制,寓意吉祥丰收。俗语过了腊八就是年,拉开过年序幕。"},
|
||||||
|
{"lunarFest", "小年", "农历腊月廿三/廿四,祭灶扫尘", "小年是春节前的重要民俗日,北方多在腊月廿三、南方多在腊月廿四。主要习俗有祭灶王爷(供糖瓜)、扫尘土、剪窗花、写春联,标志着正式进入忙年阶段。"},
|
||||||
|
{"lunarFest", "除夕", "农历腊月最后一天,守岁团圆", "除夕是岁末最后一晚,意为旧岁至此而除、另换新岁。全家团圆吃年夜饭、守岁、贴春联年画、祭祖、发压岁钱,辞旧迎新、爆竹声中一岁除,是中国人情感最浓的时刻。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 公历节日 =====
|
||||||
|
func seedSolarFestivals() []seedEntry {
|
||||||
|
return []seedEntry{
|
||||||
|
{"solarFest", "元旦", "公历1月1日,新年伊始", "元旦即公历新年,是世界多数国家通称的新年。1949年中国正式采用公历,将1月1日定为元旦。人们以联欢、倒计时、互赠贺卡等方式迎接新年,万象更新。"},
|
||||||
|
{"solarFest", "妇女节", "公历3月8日,国际劳动妇女节", "三八国际劳动妇女节源于20世纪初的妇女运动,纪念妇女争取平等权益的斗争。如今是向母亲、妻子、女友表达敬意与关爱的日子。"},
|
||||||
|
{"solarFest", "植树节", "公历3月12日,植树造林", "3月12日为中国植树节,定于1979年,纪念一贯倡导植树造林的孙中山先生(逝世纪念日)。这一天各地组织义务植树活动,绿化祖国、改善生态。"},
|
||||||
|
{"solarFest", "愚人节", "公历4月1日,善意的玩笑", "愚人节是西方民间节日,人们在4月1日以各种善意的恶作剧和玩笑互相捉弄,被捉弄者被称为四月傻瓜。玩笑适可而止,重在欢乐氛围。"},
|
||||||
|
{"solarFest", "清明节", "公历4月5日前后,扫墓踏青", "清明既是节气也是节日,兼具自然与人文内涵。人们在清明扫墓祭祖、缅怀先人,同时踏青郊游、插柳放风筝,体现天人合一的传统理念。"},
|
||||||
|
{"solarFest", "劳动节", "公历5月1日,国际劳动节", "五一国际劳动节源于1886年美国工人争取八小时工作制的运动。中国自1949年将5月1日定为法定劳动节,放假表彰劳动者,举办各类庆祝活动。"},
|
||||||
|
{"solarFest", "青年节", "公历5月4日,纪念五四运动", "五四青年节源于1919年5月4日反帝爱国的五四运动。1949年正式定为青年节,激励青年继承爱国、进步、民主、科学的精神。"},
|
||||||
|
{"solarFest", "儿童节", "公历6月1日,国际儿童节", "六一国际儿童节设立于1949年,旨在保障世界各国儿童的生存权、保健权和受教育权。这一天学校组织文艺汇演、游园活动,孩子们欢度自己的节日。"},
|
||||||
|
{"solarFest", "建党节", "公历7月1日,建党纪念日", "7月1日是中国共产党建党纪念日。1921年7月中共一大召开宣告建党,1941年正式将7月1日确立为建党纪念日,各地举行主题党日与庆祝活动。"},
|
||||||
|
{"solarFest", "建军节", "公历8月1日,八一建军节", "八一建军节源于1927年8月1日南昌起义,打响了武装反抗的第一枪。1933年确定8月1日为建军纪念日,这一天致敬中国人民解放军。"},
|
||||||
|
{"solarFest", "教师节", "公历9月10日,尊师重教", "9月10日教师节设立于1985年,旨在肯定教师为教育事业所作的贡献。开学伊始感念师恩,学生以贺卡、鲜花表达对老师的敬意。"},
|
||||||
|
{"solarFest", "国庆节", "公历10月1日,举国同庆", "10月1日国庆节纪念1949年中华人民共和国成立。黄金周假期里举行升旗仪式、阅兵(逢大庆年份)、灯光秀等活动,举国欢腾、家国同庆。"},
|
||||||
|
{"solarFest", "圣诞节", "公历12月25日,西方传统节日", "圣诞节是西方纪念耶稣诞生的传统节日,有圣诞树、圣诞老人、互赠礼物、平安夜弥撒等习俗。如今在中国更多以轻松欢乐的方式呈现,商家氛围浓厚。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 干支生肖 =====
|
||||||
|
func seedGanZhi() []seedEntry {
|
||||||
|
return []seedEntry{
|
||||||
|
{"ganzhi", "天干总说", "甲乙丙丁戊己庚辛壬癸", "天干共十个:甲、乙、丙、丁、戊、己、庚、辛、壬、癸。甲丙戊庚壬为阳干,乙丁己辛癸为阴干。天干配五行:甲乙木、丙丁火、戊己土、庚辛金、壬癸水,古人用以纪日纪年。"},
|
||||||
|
{"ganzhi", "地支总说", "子丑寅卯辰巳午未申酉戌亥", "地支共十二个:子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥。地支与十二生肖一一对应(子鼠、丑牛、寅虎、卯兔、辰龙、巳蛇、午马、未羊、申猴、酉鸡、戌狗、亥猪),并用以纪月纪时。"},
|
||||||
|
{"ganzhi", "六十甲子", "天干地支循环组合,六十年一轮回", "天干地支按顺序两两相配,从甲子到癸亥共六十个组合,称六十甲子,循环往复。用于纪年、纪月、纪日、纪时。人的六十岁生日称花甲之年,也称还历。"},
|
||||||
|
{"ganzhi", "生肖由来", "十二种动物纪年,人人有其属相", "十二生肖与十二地支相配,以动物纪年,传说源于黄帝时代或动物渡河比赛的故事。民俗中生肖与婚配、运势紧密相关,本命年有穿红戴红的习俗以趋吉避凶。"},
|
||||||
|
{"ganzhi", "五行生克", "金木水火土,相生又相克", "五行指金、木、水、火、土。相生:木生火、火生土、土生金、金生水、水生木;相克:木克土、土克水、水克火、火克金、金克木。五行学说贯穿中医、命理、风水等传统文化。"},
|
||||||
|
{"ganzhi", "阴阳学说", "万物皆分阴阳,对立统一", "阴阳是中国古代哲学的核心概念,认为万物皆有阴阳两面:天为阳地为阴、日为阳月为阴、男为阳女为阴。阴阳消长转化、对立统一,是中医与术数文化的理论基石。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 黄历术语 =====
|
||||||
|
func seedTermExplains() []seedEntry {
|
||||||
|
return []seedEntry{
|
||||||
|
{"termExplain", "干支纪年", "天干地支组合纪年法", "天干有十:甲乙丙丁戊己庚辛壬癸;地支有十二:子丑寅卯辰巳午未申酉戌亥。两者按顺序两两组合,形成六十甲子循环,用于纪年、纪月、纪日、纪时。如2024年为甲辰年,2025年为乙巳年。"},
|
||||||
|
{"termExplain", "建除十二神", "建、除、满、平、定、执、破、危、成、收、开、闭", "建除十二神又称十二值日,是古代择日的重要依据。建日宜出行,除日宜疗病,满日宜嫁娶,平日宜修造,定日宜祈福,执日宜祭祀,破日宜破屋,危日宜安床,成日宜开市,收日宜纳财,开日宜开光,闭日宜安葬。"},
|
||||||
|
{"termExplain", "黄道吉日", "青龙、明堂、金匮、天德、玉堂、司命为黄道", "黄道黑道是古代择日术语。黄道日(青龙、明堂、金匮、天德、玉堂、司命值日)诸事皆宜;黑道日(天刑、朱雀、白虎、天牢、玄武、勾陈值日)宜避凶就吉。老黄历中诸事皆宜的日子多为黄道吉日。"},
|
||||||
|
{"termExplain", "二十八宿", "古代天文学家将黄道附近的星宿分为二十八组", "二十八宿分东方青龙(角亢氐房心尾箕)、北方玄武(斗牛女虚危室壁)、西方白虎(奎娄胃昴毕觜参)、南方朱雀(井鬼柳星张翼轸)四象,每象七宿。古人用二十八宿观测天象、判断日子吉凶。"},
|
||||||
|
{"termExplain", "六曜", "先胜、友引、先负、佛灭、大安、赤口", "六曜起源于中国,后传入日本并广泛流行。大安日诸事皆吉;先胜日上午吉;友引日早晚吉;先负日上午凶;佛灭日大凶;赤口日午凶。以农历月日之和除以六取余数确定。"},
|
||||||
|
{"termExplain", "彭祖百忌", "每日天干地支对应的禁忌事项", "彭祖百忌是古代择日学的重要内容,相传彭祖寿八百岁,总结出每日干支的禁忌。如甲不开仓财物耗散,子不问卜自惹祸殃,共六十条对应六十甲子,老黄历每日宜忌之下常附此语。"},
|
||||||
|
{"termExplain", "冲煞", "日支与某生肖相冲,该生肖之人需谨慎", "冲煞指日支与某地支相冲(子午相冲、丑未相冲、寅申相冲、卯酉相冲、辰戌相冲、巳亥相冲),对应生肖之人当日宜谨慎行事。煞方指当日凶煞所在方位,不宜在该方位动土或远行。"},
|
||||||
|
{"termExplain", "纳音五行", "六十甲子与五行的配合", "纳音是将六十甲子与五行相配,每两组干支合为一种纳音,共三十种。如甲子乙丑海中金、丙寅丁卯炉中火、戊辰己巳大林木等。纳音五行常用于命理分析,俗称什么命(如海中金命)。"},
|
||||||
|
{"termExplain", "月相", "朔、上弦、望、下弦的月相变化", "农历初一为朔(新月),初七初八为上弦月,十五为望(满月),廿二廿三为下弦月,月末为晦。月相变化决定农历日期与潮汐,古人也将其用于择吉,如朔日祈福、望日团圆。"},
|
||||||
|
{"termExplain", "胎神方位", "孕妇需避开的方位", "胎神是传统民俗中保护胎儿的神灵,每日所在方位不同(如占门碓外东南)。民俗认为孕妇不宜在胎神方位动土、钉钉、搬动重物或刀剪,以免惊动胎神,属传统民俗观念。"},
|
||||||
|
{"termExplain", "吉时凶时", "一日十二时辰各有吉凶", "老黄历将一昼夜分为十二时辰(子时至亥时,每时辰两小时),并标注各时段的吉凶宜忌。举办婚礼、开业、动土等重要事宜常择吉时进行,如巳时(9-11点)开业、午时上梁等。"},
|
||||||
|
{"termExplain", "值神", "当日当值的十二位神煞", "值神即当日值事之神,与建除十二神对应:青龙、明堂、天刑、朱雀、金匮、天德、白虎、玉堂、天牢、玄武、司命、勾陈依次轮值。青龙明堂金匮天德玉堂司命六神值日为黄道吉日。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,11 @@ func AdminWishes(c *gin.Context) {
|
|||||||
adminPage(c, "许愿管理", "wishes")
|
adminPage(c, "许愿管理", "wishes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminWiki 百科管理(百科条目 + 历史上的今天同步)
|
||||||
|
func AdminWiki(c *gin.Context) {
|
||||||
|
adminPage(c, "百科管理", "wiki")
|
||||||
|
}
|
||||||
|
|
||||||
// AdminSettings 系统设置
|
// AdminSettings 系统设置
|
||||||
func AdminSettings(c *gin.Context) {
|
func AdminSettings(c *gin.Context) {
|
||||||
adminPage(c, "系统设置", "settings")
|
adminPage(c, "系统设置", "settings")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -283,6 +284,111 @@ func UpdateUserProfile(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBirthProfiles 获取当前用户的生辰档案列表
|
||||||
|
func GetBirthProfiles(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userService := service.NewUserService()
|
||||||
|
profiles, err := userService.ListBirthProfiles(userID.(uint))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取生辰档案失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"profiles": profiles,
|
||||||
|
"maxFree": model.MaxFreeBirthProfiles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBirthProfile 新增生辰档案(每用户免费上限 MaxFreeBirthProfiles 个)
|
||||||
|
func CreateBirthProfile(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Gender int `json:"gender"`
|
||||||
|
Birthday string `json:"birthday" binding:"required"`
|
||||||
|
BirthTime string `json:"birthTime" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单格式校验
|
||||||
|
if _, err := time.Parse("2006-01-02", req.Birthday); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "出生日期格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("15:04", req.BirthTime); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "出生时间格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Gender != 1 && req.Gender != 2 {
|
||||||
|
req.Gender = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := &model.BirthProfile{
|
||||||
|
UserID: userID.(uint),
|
||||||
|
Name: req.Name,
|
||||||
|
Gender: req.Gender,
|
||||||
|
Birthday: req.Birthday,
|
||||||
|
BirthTime: req.BirthTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
userService := service.NewUserService()
|
||||||
|
if err := userService.CreateBirthProfile(profile); err != nil {
|
||||||
|
if err == service.ErrBirthProfileLimit {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "新增生辰档案失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": gin.H{"profile": profile},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBirthProfile 删除生辰档案(仅限本人)
|
||||||
|
func DeleteBirthProfile(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("userID")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userService := service.NewUserService()
|
||||||
|
if err := userService.DeleteBirthProfile(uint(id), userID.(uint)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "删除生辰档案失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
// WechatAuth 微信授权(仅返回 openid,session_key 属敏感凭证不下发)
|
// WechatAuth 微信授权(仅返回 openid,session_key 属敏感凭证不下发)
|
||||||
func WechatAuth(c *gin.Context) {
|
func WechatAuth(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gouki/lunar-server/internal/config"
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
|
"github.com/gouki/lunar-server/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiSync 维基百科同步服务实例(main 中注入)
|
||||||
|
var WikiSync *service.WikiSyncService
|
||||||
|
|
||||||
|
// BaikeTermSync 节气详情同步服务实例(main 中注入,独立于维基同步)
|
||||||
|
var BaikeTermSync *service.BaikeTermSyncService
|
||||||
|
|
||||||
|
// ===== 公开接口(小程序) =====
|
||||||
|
|
||||||
|
// GetWikiEntries 百科条目列表(小程序百科页)
|
||||||
|
func GetWikiEntries(c *gin.Context) {
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
entries, err := wikiService.ListEntries(c.Query("category"), c.Query("keyword"), 1)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "获取百科条目失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"list": entries,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnThisDay 历史上的今天(含大事记/出生/逝世/节假日)
|
||||||
|
func GetOnThisDay(c *gin.Context) {
|
||||||
|
month, _ := strconv.Atoi(c.Query("month"))
|
||||||
|
day, _ := strconv.Atoi(c.Query("day"))
|
||||||
|
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
result, err := wikiService.GetOnThisDay(month, day)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": 400,
|
||||||
|
"msg": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 管理后台接口 =====
|
||||||
|
|
||||||
|
// AdminWikiEntries 百科条目列表(含隐藏条目)
|
||||||
|
func AdminWikiEntries(c *gin.Context) {
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
entries, err := wikiService.ListEntries(c.Query("category"), c.Query("keyword"), -1)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取百科条目失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": gin.H{"list": entries},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWikiEntryCreate 新增百科条目
|
||||||
|
func AdminWikiEntryCreate(c *gin.Context) {
|
||||||
|
var entry model.WikiEntry
|
||||||
|
if err := c.ShouldBindJSON(&entry); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
if err := wikiService.CreateEntry(&entry); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": entry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWikiEntryUpdate 更新百科条目
|
||||||
|
func AdminWikiEntryUpdate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "无效的ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry model.WikiEntry
|
||||||
|
if err := c.ShouldBindJSON(&entry); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
if err := wikiService.UpdateEntry(uint(id), &entry); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "更新失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWikiEntryDelete 删除百科条目
|
||||||
|
func AdminWikiEntryDelete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "无效的ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
if err := wikiService.DeleteEntry(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWikiSyncStatus 维基百科同步状态
|
||||||
|
func AdminWikiSyncStatus(c *gin.Context) {
|
||||||
|
wikiService := service.NewWikiService()
|
||||||
|
status, err := wikiService.GetSyncStatus(WikiSync != nil && WikiSync.IsRunning())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取同步状态失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWikiSyncTrigger 手动触发全量同步(异步执行)
|
||||||
|
func AdminWikiSyncTrigger(c *gin.Context) {
|
||||||
|
if WikiSync == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "同步服务未初始化"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !WikiSync.TriggerSync("admin", true) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "msg": "已有同步任务正在执行,请稍后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "同步任务已启动"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 节气详情同步(百度百科,独立于维基) =====
|
||||||
|
|
||||||
|
// AdminTermSyncStatus 节气详情同步状态(X/24 已含详情 + 最近一次同步日志)
|
||||||
|
func AdminTermSyncStatus(c *gin.Context) {
|
||||||
|
db := config.GetDB()
|
||||||
|
var detailed int64
|
||||||
|
db.Model(&model.WikiEntry{}).Where("category = ? AND image != ''", "term").Count(&detailed)
|
||||||
|
|
||||||
|
var lastLog model.BaikeTermSyncLog
|
||||||
|
db.Order("started_at DESC").First(&lastLog)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": gin.H{
|
||||||
|
"running": BaikeTermSync != nil && BaikeTermSync.IsRunning(),
|
||||||
|
"detailedTerms": detailed,
|
||||||
|
"totalTerms": 24,
|
||||||
|
"lastSync": lastLog,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminTermSyncTrigger 手动触发节气详情同步(异步执行)
|
||||||
|
func AdminTermSyncTrigger(c *gin.Context) {
|
||||||
|
if BaikeTermSync == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "节气同步服务未初始化"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !BaikeTermSync.TriggerSync("admin", true) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "msg": "已有节气同步任务正在执行,请稍后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "节气同步任务已启动"})
|
||||||
|
}
|
||||||
@@ -104,6 +104,7 @@ func Auth() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
||||||
|
// 浏览器导航(GET 页面请求)未登录时渲染登录页;API 请求未登录时返回 401 JSON
|
||||||
func AdminAuth() gin.HandlerFunc {
|
func AdminAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenString := ""
|
tokenString := ""
|
||||||
@@ -115,18 +116,24 @@ func AdminAuth() gin.HandlerFunc {
|
|||||||
tokenString, _ = c.Cookie(service.AdminTokenCookie)
|
tokenString, _ = c.Cookie(service.AdminTokenCookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenString == "" {
|
authed := false
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
if tokenString != "" {
|
||||||
"code": 401,
|
cfg := config.Load()
|
||||||
"msg": "需要管理员权限",
|
userService := service.NewUserService()
|
||||||
|
authed = userService.IsAdminToken(tokenString, cfg.JWT.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authed {
|
||||||
|
// 页面导航(GET 且非 /admin/api/):返回登录页
|
||||||
|
if c.Request.Method == http.MethodGet && !strings.HasPrefix(c.Request.URL.Path, "/admin/api/") {
|
||||||
|
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
|
"title": "管理员登录",
|
||||||
|
"page": "login",
|
||||||
|
"url": c.Request.URL.Path,
|
||||||
})
|
})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.Load()
|
|
||||||
userService := service.NewUserService()
|
|
||||||
if !userService.IsAdminToken(tokenString, cfg.JWT.Secret) {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
"code": 401,
|
"code": 401,
|
||||||
"msg": "需要管理员权限",
|
"msg": "需要管理员权限",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BaikeTermSyncLog 节气详情同步日志(独立于维基 WikiSyncLog,互不影响)
|
||||||
|
type BaikeTermSyncLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Trigger string `gorm:"size:20" json:"trigger"` // startup:启动自动 cron:定时任务 admin:手动
|
||||||
|
Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败
|
||||||
|
Pages int `json:"pages"` // 总节气数(24)
|
||||||
|
Success int `json:"success"` // 成功数
|
||||||
|
Failed int `json:"failed"` // 失败数
|
||||||
|
Error string `gorm:"size:500" json:"error"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt"` // 同步完成时间,进行中为 NULL
|
||||||
|
}
|
||||||
@@ -32,3 +32,18 @@ type UserProfile struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaxFreeBirthProfiles 每个用户可免费绑定的生辰数量上限
|
||||||
|
const MaxFreeBirthProfiles = 3
|
||||||
|
|
||||||
|
// BirthProfile 用户生辰档案(八字绑定),每个用户最多免费绑定 MaxFreeBirthProfiles 个
|
||||||
|
type BirthProfile struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"index" json:"userId"`
|
||||||
|
Name string `gorm:"size:32" json:"name"` // 备注名(如本人、家人等)
|
||||||
|
Gender int `gorm:"default:1" json:"gender"` // 1:男 2:女
|
||||||
|
Birthday string `gorm:"size:10" json:"birthday"` // YYYY-MM-DD
|
||||||
|
BirthTime string `gorm:"size:8" json:"birthTime"` // HH:mm
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiEntry 百科条目(小程序百科页,可在管理后台维护)
|
||||||
|
type WikiEntry struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Category string `gorm:"size:30;index" json:"category"` // term:节气 lunarFest:农历节日 solarFest:公历节日 ganzhi:干支生肖 termExplain:黄历术语 other:其他
|
||||||
|
Title string `gorm:"size:50" json:"title"`
|
||||||
|
Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示)
|
||||||
|
Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示)
|
||||||
|
Image string `gorm:"size:255" json:"image"` // 配图 URL(节气等条目的配图)
|
||||||
|
Extra string `gorm:"type:text" json:"extra"` // 扩展字段 JSON(节气:别名/外文名/物候/习俗/养生等)
|
||||||
|
Sort int `gorm:"default:0" json:"sort"`
|
||||||
|
Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiOnThisDay 历史上的今天(数据来源:中文维基百科)
|
||||||
|
type WikiOnThisDay struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Month int `gorm:"index:idx_otd_day" json:"month"` // 1-12
|
||||||
|
Day int `gorm:"index:idx_otd_day" json:"day"` // 1-31
|
||||||
|
Kind string `gorm:"size:20;index:idx_otd_day" json:"kind"` // event:大事记 birth:出生 death:逝世 festival:节假日习俗
|
||||||
|
Year int `json:"year"` // 0:年份不详,负数:公元前
|
||||||
|
Content string `gorm:"type:text" json:"content"`
|
||||||
|
Status int `gorm:"default:1" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiSyncLog 维基百科同步日志
|
||||||
|
type WikiSyncLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Trigger string `gorm:"size:20" json:"trigger"` // startup:schema自动 cron:定时任务 admin:手动
|
||||||
|
Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败
|
||||||
|
Pages int `json:"pages"` // 总页数
|
||||||
|
Success int `json:"success"` // 成功页数
|
||||||
|
Failed int `json:"failed"` // 失败页数
|
||||||
|
Error string `gorm:"size:500" json:"error"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt"` // 同步完成时间,进行中为 NULL
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type Wish struct {
|
|||||||
Position int `gorm:"default:0" json:"position"` // 位置(用于排序/覆盖)
|
Position int `gorm:"default:0" json:"position"` // 位置(用于排序/覆盖)
|
||||||
Status int `gorm:"default:1" json:"status"` // 1:正常 0:隐藏/删除
|
Status int `gorm:"default:1" json:"status"` // 1:正常 0:隐藏/删除
|
||||||
IsRobot bool `gorm:"default:false" json:"isRobot"` // 是否机器人发布
|
IsRobot bool `gorm:"default:false" json:"isRobot"` // 是否机器人发布
|
||||||
|
IsTop bool `gorm:"-" json:"isTop"` // 是否顶级位置(运行时计算)
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,7 @@ type WishTree struct {
|
|||||||
Name string `gorm:"size:50" json:"name"`
|
Name string `gorm:"size:50" json:"name"`
|
||||||
Description string `gorm:"size:255" json:"description"`
|
Description string `gorm:"size:255" json:"description"`
|
||||||
Type string `gorm:"size:20;default:'pine'" json:"type"` // pine:松树 sakura:樱花 bamboo:竹子
|
Type string `gorm:"size:20;default:'pine'" json:"type"` // pine:松树 sakura:樱花 bamboo:竹子
|
||||||
|
Image string `gorm:"size:255" json:"image"` // 背景图地址
|
||||||
MaxWishes int `gorm:"default:100" json:"maxWishes"` // 最大许愿条数
|
MaxWishes int `gorm:"default:100" json:"maxWishes"` // 最大许愿条数
|
||||||
Sort int `gorm:"default:0" json:"sort"` // 排序
|
Sort int `gorm:"default:0" json:"sort"` // 排序
|
||||||
Status int `gorm:"default:1" json:"status"`
|
Status int `gorm:"default:1" json:"status"`
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BaikeTermSyncService 二十四节气详情同步服务
|
||||||
|
// 数据源:百度百科开放接口 BaikeLemmaCardApi(词条卡片,含简介/配图/信息卡)
|
||||||
|
// 与「历史上的今天」(WikiSyncService)完全独立,互不影响
|
||||||
|
type BaikeTermSyncService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
running int32 // 原子标记:是否有同步正在执行
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBaikeTermSyncService 创建节气同步服务
|
||||||
|
func NewBaikeTermSyncService(db *gorm.DB) *BaikeTermSyncService {
|
||||||
|
return &BaikeTermSyncService{
|
||||||
|
db: db,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// solarTermNames 二十四节气(顺序即 Sort 排序)
|
||||||
|
var solarTermNames = []string{
|
||||||
|
"立春", "雨水", "惊蛰", "春分", "清明", "谷雨",
|
||||||
|
"立夏", "小满", "芒种", "夏至", "小暑", "大暑",
|
||||||
|
"立秋", "处暑", "白露", "秋分", "寒露", "霜降",
|
||||||
|
"立冬", "小雪", "大雪", "冬至", "小寒", "大寒",
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// reTermSup 匹配 <sup>...</sup> 引用标记(含内容一并去除,避免残留引用编号)
|
||||||
|
reTermSup = regexp.MustCompile(`(?s)<sup[^>]*>.*?</sup>`)
|
||||||
|
reTermHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||||
|
reTermSpace = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsRunning 是否有同步任务正在执行
|
||||||
|
func (s *BaikeTermSyncService) IsRunning() bool {
|
||||||
|
return atomic.LoadInt32(&s.running) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailedTermCount 已含详情(image 非空)的节气条目数
|
||||||
|
func (s *BaikeTermSyncService) detailedTermCount() int64 {
|
||||||
|
var count int64
|
||||||
|
s.db.Model(&model.WikiEntry{}).
|
||||||
|
Where("category = ? AND image != ''", "term").
|
||||||
|
Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSync 触发一次节气同步(异步执行);已有任务执行中时返回 false
|
||||||
|
// full=true 全量刷新 24 节气;full=false 仅补齐缺详情的节气
|
||||||
|
func (s *BaikeTermSyncService) TriggerSync(trigger string, full bool) bool {
|
||||||
|
names := solarTermNames
|
||||||
|
if !full {
|
||||||
|
names = s.missingTerms()
|
||||||
|
if len(names) == 0 {
|
||||||
|
return false // 无缺失,无需同步
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer atomic.StoreInt32(&s.running, 0)
|
||||||
|
s.runSync(trigger, names)
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingTerms 尚未抓取详情的节气(image 为空)
|
||||||
|
func (s *BaikeTermSyncService) missingTerms() []string {
|
||||||
|
var existing []string
|
||||||
|
s.db.Model(&model.WikiEntry{}).
|
||||||
|
Where("category = ? AND image != ''", "term").
|
||||||
|
Pluck("title", &existing)
|
||||||
|
set := make(map[string]bool, len(existing))
|
||||||
|
for _, t := range existing {
|
||||||
|
set[t] = true
|
||||||
|
}
|
||||||
|
missing := make([]string, 0, len(solarTermNames))
|
||||||
|
for _, name := range solarTermNames {
|
||||||
|
if !set[name] {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScheduler 启动定时同步任务:
|
||||||
|
// - 启动时若节气详情缺失,延迟 15 秒后自动补齐
|
||||||
|
// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行
|
||||||
|
func (s *BaikeTermSyncService) StartScheduler(intervalDays, syncHour int) {
|
||||||
|
go func() {
|
||||||
|
if missing := s.missingTerms(); len(missing) > 0 {
|
||||||
|
log.Printf("term sync: 缺失 %d/24 节气详情,15秒后自动补齐", len(missing))
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
s.TriggerSync("startup", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if intervalDays <= 0 {
|
||||||
|
intervalDays = 7
|
||||||
|
}
|
||||||
|
if syncHour < 0 || syncHour > 23 {
|
||||||
|
syncHour = 4
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
if now.Hour() != syncHour {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if last, ok := s.lastSuccessAt(); ok && time.Since(last) < time.Duration(intervalDays)*24*time.Hour {
|
||||||
|
continue // 未到刷新周期
|
||||||
|
}
|
||||||
|
log.Println("term sync: 定时任务触发全量同步")
|
||||||
|
s.TriggerSync("cron", true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间
|
||||||
|
func (s *BaikeTermSyncService) lastSuccessAt() (time.Time, bool) {
|
||||||
|
var syncLog model.BaikeTermSyncLog
|
||||||
|
err := s.db.Where("status IN ?", []string{"success", "partial"}).
|
||||||
|
Order("finished_at DESC").First(&syncLog).Error
|
||||||
|
if err != nil || syncLog.FinishedAt == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return *syncLog.FinishedAt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSync 同步指定节气集合(全量 24 或缺失补齐)
|
||||||
|
func (s *BaikeTermSyncService) runSync(trigger string, names []string) {
|
||||||
|
syncLog := model.BaikeTermSyncLog{
|
||||||
|
Trigger: trigger,
|
||||||
|
Status: "running",
|
||||||
|
Pages: len(names),
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
}
|
||||||
|
s.db.Create(&syncLog)
|
||||||
|
|
||||||
|
var success, failed int32
|
||||||
|
for _, name := range names {
|
||||||
|
if err := s.syncOneTerm(name); err != nil {
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
log.Printf("term sync: %s 失败: %v", name, err)
|
||||||
|
} else {
|
||||||
|
atomic.AddInt32(&success, 1)
|
||||||
|
}
|
||||||
|
time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发限流
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
if failed > 0 {
|
||||||
|
status = "partial"
|
||||||
|
}
|
||||||
|
if success == 0 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.db.Model(&syncLog).Updates(map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"success": int(success),
|
||||||
|
"failed": int(failed),
|
||||||
|
"finished_at": &now,
|
||||||
|
})
|
||||||
|
log.Printf("term sync: 同步完成 trigger=%s terms=%d success=%d failed=%d", trigger, len(names), success, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 百度百科节气抓取与解析 =====
|
||||||
|
|
||||||
|
// baikeCardItem 信息卡单项
|
||||||
|
type baikeCardItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value []string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// baikeLemmaCard 百度百科词条卡片返回结构(只取需要的字段)
|
||||||
|
type baikeLemmaCard struct {
|
||||||
|
Key string `json:"key"` // 词条名
|
||||||
|
Desc string `json:"desc"` // 一句话描述
|
||||||
|
Title string `json:"title"` // 标题
|
||||||
|
Abstract string `json:"abstract"`// 简介(含 HTML)
|
||||||
|
Image string `json:"image"` // 配图 URL
|
||||||
|
Card []baikeCardItem `json:"card"` // 信息卡
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncOneTerm 抓取单个节气详情并写入/更新 WikiEntry
|
||||||
|
func (s *BaikeTermSyncService) syncOneTerm(name string) error {
|
||||||
|
lemma, err := s.fetchLemma(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析信息卡为扩展字段
|
||||||
|
extra := map[string]string{}
|
||||||
|
for _, c := range lemma.Card {
|
||||||
|
text := cleanTermHTML(firstValue(c.Value))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c.Name {
|
||||||
|
case "别名":
|
||||||
|
extra["alias"] = text
|
||||||
|
case "外文名":
|
||||||
|
extra["english"] = text
|
||||||
|
case "涵义":
|
||||||
|
extra["meaning"] = text
|
||||||
|
case "公历时间":
|
||||||
|
extra["solarDate"] = text
|
||||||
|
case "黄道位置":
|
||||||
|
extra["ecliptic"] = text
|
||||||
|
case "气候特点":
|
||||||
|
extra["climate"] = text
|
||||||
|
case "物候现象":
|
||||||
|
extra["phenology"] = text
|
||||||
|
case "农事活动":
|
||||||
|
extra["farming"] = text
|
||||||
|
case "传统习俗":
|
||||||
|
extra["customs"] = text
|
||||||
|
case "起居养生":
|
||||||
|
extra["health"] = text
|
||||||
|
case "花信":
|
||||||
|
extra["flower"] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraJSON, _ := json.Marshal(extra)
|
||||||
|
|
||||||
|
content := trimTruncatedTail(cleanTermHTML(lemma.Abstract))
|
||||||
|
brief := extra["meaning"]
|
||||||
|
if brief == "" {
|
||||||
|
brief = firstSentence(content)
|
||||||
|
}
|
||||||
|
if len([]rune(brief)) > 120 {
|
||||||
|
brief = string([]rune(brief)[:120])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 category=term AND title=name 查找,存在则更新,不存在则新建
|
||||||
|
var entry model.WikiEntry
|
||||||
|
err = s.db.Where("category = ? AND title = ?", "term", name).First(&entry).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
entry = model.WikiEntry{
|
||||||
|
Category: "term",
|
||||||
|
Title: name,
|
||||||
|
Brief: brief,
|
||||||
|
Content: content,
|
||||||
|
Image: lemma.Image,
|
||||||
|
Extra: string(extraJSON),
|
||||||
|
Sort: termSort(name),
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
return s.db.Create(&entry).Error
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Model(&entry).Updates(map[string]interface{}{
|
||||||
|
"brief": brief,
|
||||||
|
"content": content,
|
||||||
|
"image": lemma.Image,
|
||||||
|
"extra": string(extraJSON),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLemma 调用百度百科 BaikeLemmaCardApi 抓取词条卡片(失败重试)
|
||||||
|
func (s *BaikeTermSyncService) fetchLemma(name string) (*baikeLemmaCard, error) {
|
||||||
|
apiURL := "https://baike.baidu.com/api/openapi/BaikeLemmaCardApi?scope=103&format=json&appid=379020&bk_length=600&bk_key=" + url.QueryEscape(name)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 4; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt*3) * time.Second)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||||||
|
req.Header.Set("Referer", "https://baike.baidu.com/")
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 上限 2MB
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var lemma baikeLemmaCard
|
||||||
|
if err := json.Unmarshal(body, &lemma); err != nil {
|
||||||
|
lastErr = fmt.Errorf("JSON 解析失败: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lemma.Abstract == "" && len(lemma.Card) == 0 {
|
||||||
|
lastErr = fmt.Errorf("%s 返回内容为空", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &lemma, nil
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// termSort 节气在列表中的排序(按节气顺序)
|
||||||
|
func termSort(name string) int {
|
||||||
|
for i, n := range solarTermNames {
|
||||||
|
if n == name {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstValue 取信息卡 value 的第一个值
|
||||||
|
func firstValue(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstSentence 取文本首句(按句号/感叹号/问号切分)
|
||||||
|
func firstSentence(s string) string {
|
||||||
|
for _, sep := range []string{"。", "!", "?"} {
|
||||||
|
if idx := strings.Index(s, sep); idx > 0 {
|
||||||
|
return s[:idx+len(sep)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanTermHTML 去除 HTML 标签(含 sup 引用标记整体)与多余空白,输出纯文本(本地实现,与维基同步解耦)
|
||||||
|
func cleanTermHTML(s string) string {
|
||||||
|
s = reTermSup.ReplaceAllString(s, "") // 先去 <sup>引用编号</sup>(含内容)
|
||||||
|
s = reTermHTMLTag.ReplaceAllString(s, "")
|
||||||
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
|
s = reTermSpace.ReplaceAllString(s, " ")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimTruncatedTail 清理百度百科摘要的截断结尾:
|
||||||
|
// 摘要以「...」「…」等结尾说明被接口截断,先去掉省略号;
|
||||||
|
// 若去掉后末句不是完整句(不以 。!? 结尾),回退到最后一个完整句标点,避免半截话
|
||||||
|
func trimTruncatedTail(s string) string {
|
||||||
|
// 去掉结尾的省略号(英文 ... / 中文 … / 多个点)
|
||||||
|
s = strings.TrimRight(s, ".… ")
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// 末字符已是完整句标点则无需回退
|
||||||
|
last := []rune(s)
|
||||||
|
if len(last) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if strings.ContainsRune("。!?", last[len(last)-1]) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// 回退到最后一个完整句标点(LastIndex 未找到返回 -1,需先判断 j >= 0)
|
||||||
|
idx := -1
|
||||||
|
for _, sep := range []string{"。", "!", "?"} {
|
||||||
|
if j := strings.LastIndex(s, sep); j >= 0 && j+len(sep) > idx {
|
||||||
|
idx = j + len(sep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx > 0 {
|
||||||
|
return s[:idx]
|
||||||
|
}
|
||||||
|
// 没有完整句标点则保留原文(避免清空)
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 取自百度百科 BaikeLemmaCardApi 「立春」的真实返回片段
|
||||||
|
const sampleLemmaJSON = `{
|
||||||
|
"id":25702,"key":"立春","desc":"二十四节气之一","title":"立春",
|
||||||
|
"image":"https://bkimg.cdn.bcebos.com/pic/d62a6059252dd42a2834edd463674cb5c9ea15ce1205",
|
||||||
|
"abstract":"立春(Beginning of Spring),又名正月节、岁节、改岁、岁旦、打春等,为二十四节气之首。当太阳到达黄经315°时为立春,于每年公历2月3-5日交节。",
|
||||||
|
"card":[
|
||||||
|
{"key":"m25_nameC","name":"中文名","value":["立春"]},
|
||||||
|
{"key":"m25_nameE","name":"外文名","value":["Beginning of Spring<sup>3</sup><a name=\"ref_3\"></a>","Lichun"]},
|
||||||
|
{"key":"m25_alternateName","name":"别名","value":["岁首、岁节、改岁、立春节、正月节"]},
|
||||||
|
{"key":"m25_meaning","name":"涵义","value":["阳气上升,万物更生,新岁开启"]},
|
||||||
|
{"key":"m25_solarDate","name":"公历时间","value":["公历2月3日-2月5日交节"]},
|
||||||
|
{"key":"m25_ecliptic","name":"黄道位置","value":["太阳到达黄经315度"]},
|
||||||
|
{"key":"m25_phenology","name":"物候现象","value":["东风解冻、蛰虫始振、鱼陟负冰"]},
|
||||||
|
{"key":"m25_farming","name":"农事活动","value":["备耕春播"]},
|
||||||
|
{"key":"m25_customs","name":"传统习俗","value":["<a target=_blank href=\"/item/x\">演春</a>、迎春、打春"]},
|
||||||
|
{"key":"m25_health","name":"起居养生","value":["防躁动,避免过度劳累,保证睡眠情绪豁达。"]},
|
||||||
|
{"key":"m25_flower","name":"花信","value":["迎春、樱桃、望春<sup>35</sup><a name=\"ref_35\"></a>"]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestParseLemmaCard(t *testing.T) {
|
||||||
|
var lemma baikeLemmaCard
|
||||||
|
if err := json.Unmarshal([]byte(sampleLemmaJSON), &lemma); err != nil {
|
||||||
|
t.Fatalf("JSON 解析失败: %v", err)
|
||||||
|
}
|
||||||
|
if lemma.Key != "立春" {
|
||||||
|
t.Errorf("key = %q, want 立春", lemma.Key)
|
||||||
|
}
|
||||||
|
if lemma.Image == "" {
|
||||||
|
t.Error("image 不应为空")
|
||||||
|
}
|
||||||
|
if !strings.Contains(lemma.Abstract, "二十四节气之首") {
|
||||||
|
t.Errorf("abstract 应含「二十四节气之首」, got %q", lemma.Abstract)
|
||||||
|
}
|
||||||
|
if len(lemma.Card) == 0 {
|
||||||
|
t.Fatal("card 不应为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLemmaCardMapping(t *testing.T) {
|
||||||
|
var lemma baikeLemmaCard
|
||||||
|
json.Unmarshal([]byte(sampleLemmaJSON), &lemma)
|
||||||
|
|
||||||
|
extra := map[string]string{}
|
||||||
|
for _, c := range lemma.Card {
|
||||||
|
text := cleanTermHTML(firstValue(c.Value))
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c.Name {
|
||||||
|
case "别名":
|
||||||
|
extra["alias"] = text
|
||||||
|
case "外文名":
|
||||||
|
extra["english"] = text
|
||||||
|
case "涵义":
|
||||||
|
extra["meaning"] = text
|
||||||
|
case "物候现象":
|
||||||
|
extra["phenology"] = text
|
||||||
|
case "传统习俗":
|
||||||
|
extra["customs"] = text
|
||||||
|
case "起居养生":
|
||||||
|
extra["health"] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra["alias"] != "岁首、岁节、改岁、立春节、正月节" {
|
||||||
|
t.Errorf("alias = %q", extra["alias"])
|
||||||
|
}
|
||||||
|
if extra["meaning"] != "阳气上升,万物更生,新岁开启" {
|
||||||
|
t.Errorf("meaning = %q", extra["meaning"])
|
||||||
|
}
|
||||||
|
if extra["phenology"] != "东风解冻、蛰虫始振、鱼陟负冰" {
|
||||||
|
t.Errorf("phenology = %q", extra["phenology"])
|
||||||
|
}
|
||||||
|
// 传统习俗含 HTML 链接,应被清洗
|
||||||
|
if strings.Contains(extra["customs"], "<a") || strings.Contains(extra["customs"], "href") {
|
||||||
|
t.Errorf("customs 应清洗 HTML, got %q", extra["customs"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(extra["customs"], "演春") {
|
||||||
|
t.Errorf("customs 应保留文字「演春」, got %q", extra["customs"])
|
||||||
|
}
|
||||||
|
// 外文名含 <sup> 应清洗
|
||||||
|
if strings.Contains(extra["english"], "<sup>") {
|
||||||
|
t.Errorf("english 应清洗 sup 标签, got %q", extra["english"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanTermHTML(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
`<a target=_blank href="/item/x">演春</a>、迎春`: "演春、迎春",
|
||||||
|
`Beginning of Spring<sup>3</sup><a name="ref_3"></a>`: "Beginning of Spring",
|
||||||
|
` 多余 空白 `: "多余 空白",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := cleanTermHTML(in); got != want {
|
||||||
|
t.Errorf("cleanTermHTML(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstSentence(t *testing.T) {
|
||||||
|
s := "立春为二十四节气之首。当太阳到达黄经315°时为立春。"
|
||||||
|
if got := firstSentence(s); got != "立春为二十四节气之首。" {
|
||||||
|
t.Errorf("firstSentence = %q", got)
|
||||||
|
}
|
||||||
|
// 无句号时返回原文
|
||||||
|
if got := firstSentence("无句号文本"); got != "无句号文本" {
|
||||||
|
t.Errorf("firstSentence 无句号 = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTermSort(t *testing.T) {
|
||||||
|
if termSort("立春") != 1 {
|
||||||
|
t.Errorf("termSort(立春) = %d, want 1", termSort("立春"))
|
||||||
|
}
|
||||||
|
if termSort("大寒") != 24 {
|
||||||
|
t.Errorf("termSort(大寒) = %d, want 24", termSort("大寒"))
|
||||||
|
}
|
||||||
|
if termSort("不存在") != 0 {
|
||||||
|
t.Errorf("termSort(不存在) = %d, want 0", termSort("不存在"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrimTruncatedTail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"完整句号结尾不动", "夏至后人们普遍食用清补凉汤来避暑。", "夏至后人们普遍食用清补凉汤来避暑。"},
|
||||||
|
{"英文省略号回退", "表达感谢的心情。唐宋时期,冬至...", "表达感谢的心情。"},
|
||||||
|
{"中文省略号回退", "表达感谢的心情。唐宋时期,冬至…", "表达感谢的心情。"},
|
||||||
|
{"无句号半截话保留", "可明显感觉到早春的气...", "可明显感觉到早春的气"},
|
||||||
|
{"多点省略号", "农事谚语....", "农事谚语"},
|
||||||
|
{"结尾空白加省略号", "冬至大如年。 ...", "冬至大如年。"},
|
||||||
|
{"空字符串", "", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := trimTruncatedTail(c.in); got != c.want {
|
||||||
|
t.Errorf("%s: trimTruncatedTail(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -70,6 +71,35 @@ func (s *UserService) GetUserProfile(userID uint) (*model.UserProfile, error) {
|
|||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrBirthProfileLimit 生辰档案数量达到免费上限
|
||||||
|
var ErrBirthProfileLimit = fmt.Errorf("最多可免费绑定 %d 个生辰", model.MaxFreeBirthProfiles)
|
||||||
|
|
||||||
|
// ListBirthProfiles 获取用户的全部生辰档案
|
||||||
|
func (s *UserService) ListBirthProfiles(userID uint) ([]model.BirthProfile, error) {
|
||||||
|
var profiles []model.BirthProfile
|
||||||
|
if err := s.db.Where("user_id = ?", userID).Order("id ASC").Find(&profiles).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBirthProfile 新增生辰档案,超过免费上限时返回 ErrBirthProfileLimit
|
||||||
|
func (s *UserService) CreateBirthProfile(profile *model.BirthProfile) error {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&model.BirthProfile{}).Where("user_id = ?", profile.UserID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count >= model.MaxFreeBirthProfiles {
|
||||||
|
return ErrBirthProfileLimit
|
||||||
|
}
|
||||||
|
return s.db.Create(profile).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBirthProfile 删除生辰档案(仅限本人)
|
||||||
|
func (s *UserService) DeleteBirthProfile(id, userID uint) error {
|
||||||
|
return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.BirthProfile{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateToken 生成JWT Token
|
// GenerateToken 生成JWT Token
|
||||||
func (s *UserService) GenerateToken(userID uint, secret string) (string, error) {
|
func (s *UserService) GenerateToken(userID uint, secret string) (string, error) {
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gouki/lunar-server/internal/config"
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiService 百科服务
|
||||||
|
type WikiService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWikiService 创建百科服务
|
||||||
|
func NewWikiService() *WikiService {
|
||||||
|
return &WikiService{db: config.GetDB()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEntries 百科条目列表(status<0 表示不过滤状态,供管理后台使用)
|
||||||
|
func (s *WikiService) ListEntries(category, keyword string, status int) ([]*model.WikiEntry, error) {
|
||||||
|
query := s.db.Model(&model.WikiEntry{})
|
||||||
|
if status >= 0 {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if category != "" {
|
||||||
|
query = query.Where("category = ?", category)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
query = query.Where("title LIKE ? OR brief LIKE ? OR content LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []*model.WikiEntry
|
||||||
|
if err := query.Order("sort ASC, id ASC").Find(&entries).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntry 新增百科条目
|
||||||
|
func (s *WikiService) CreateEntry(entry *model.WikiEntry) error {
|
||||||
|
if entry.Category == "" || entry.Title == "" {
|
||||||
|
return errors.New("分类和标题不能为空")
|
||||||
|
}
|
||||||
|
if entry.Status == 0 {
|
||||||
|
entry.Status = 1
|
||||||
|
}
|
||||||
|
return s.db.Create(entry).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEntry 更新百科条目
|
||||||
|
func (s *WikiService) UpdateEntry(id uint, entry *model.WikiEntry) error {
|
||||||
|
var existing model.WikiEntry
|
||||||
|
if err := s.db.First(&existing, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Model(&existing).Updates(map[string]interface{}{
|
||||||
|
"category": entry.Category,
|
||||||
|
"title": entry.Title,
|
||||||
|
"brief": entry.Brief,
|
||||||
|
"content": entry.Content,
|
||||||
|
"image": entry.Image,
|
||||||
|
"extra": entry.Extra,
|
||||||
|
"sort": entry.Sort,
|
||||||
|
"status": entry.Status,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEntry 删除百科条目
|
||||||
|
func (s *WikiService) DeleteEntry(id uint) error {
|
||||||
|
return s.db.Delete(&model.WikiEntry{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnThisDayResult 某一天的历史数据
|
||||||
|
type OnThisDayResult struct {
|
||||||
|
Events []model.WikiOnThisDay `json:"events"`
|
||||||
|
Births []model.WikiOnThisDay `json:"births"`
|
||||||
|
Deaths []model.WikiOnThisDay `json:"deaths"`
|
||||||
|
Festivals []model.WikiOnThisDay `json:"festivals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnThisDay 查询某一天的历史上的今天数据(各板块限量返回)
|
||||||
|
func (s *WikiService) GetOnThisDay(month, day int) (*OnThisDayResult, error) {
|
||||||
|
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||||
|
return nil, errors.New("日期参数无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
var all []model.WikiOnThisDay
|
||||||
|
if err := s.db.Where("month = ? AND day = ? AND status = 1", month, day).
|
||||||
|
Order("year ASC, id ASC").
|
||||||
|
Find(&all).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &OnThisDayResult{
|
||||||
|
Events: make([]model.WikiOnThisDay, 0),
|
||||||
|
Births: make([]model.WikiOnThisDay, 0),
|
||||||
|
Deaths: make([]model.WikiOnThisDay, 0),
|
||||||
|
Festivals: make([]model.WikiOnThisDay, 0),
|
||||||
|
}
|
||||||
|
limits := map[string]int{"event": 20, "birth": 15, "death": 10, "festival": 10}
|
||||||
|
for _, item := range all {
|
||||||
|
switch item.Kind {
|
||||||
|
case "event":
|
||||||
|
if len(result.Events) < limits["event"] {
|
||||||
|
result.Events = append(result.Events, item)
|
||||||
|
}
|
||||||
|
case "birth":
|
||||||
|
if len(result.Births) < limits["birth"] {
|
||||||
|
result.Births = append(result.Births, item)
|
||||||
|
}
|
||||||
|
case "death":
|
||||||
|
if len(result.Deaths) < limits["death"] {
|
||||||
|
result.Deaths = append(result.Deaths, item)
|
||||||
|
}
|
||||||
|
case "festival":
|
||||||
|
if len(result.Festivals) < limits["festival"] {
|
||||||
|
result.Festivals = append(result.Festivals, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus 同步状态(管理后台展示)
|
||||||
|
type SyncStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
TotalDays int64 `json:"totalDays"` // 已覆盖的天数
|
||||||
|
TotalItems int64 `json:"totalItems"` // 总条目数
|
||||||
|
LastSync *model.WikiSyncLog `json:"lastSync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncStatus 获取同步状态
|
||||||
|
func (s *WikiService) GetSyncStatus(running bool) (*SyncStatus, error) {
|
||||||
|
status := &SyncStatus{Running: running}
|
||||||
|
|
||||||
|
var totalDays int64
|
||||||
|
s.db.Model(&model.WikiOnThisDay{}).Where("status = 1").
|
||||||
|
Select("COUNT(DISTINCT month, day) AS c").Scan(&totalDays)
|
||||||
|
status.TotalDays = totalDays
|
||||||
|
s.db.Model(&model.WikiOnThisDay{}).Where("status = 1").Count(&status.TotalItems)
|
||||||
|
|
||||||
|
var lastSync model.WikiSyncLog
|
||||||
|
if err := s.db.Order("id DESC").First(&lastSync).Error; err == nil {
|
||||||
|
status.LastSync = &lastSync
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiSyncService 「历史上的今天」同步服务
|
||||||
|
// 数据源:百度百科「历史上的今天」开放接口(按月返回结构化 JSON,共 12 次请求)
|
||||||
|
// 首次抓取后按配置间隔定期增量刷新
|
||||||
|
type WikiSyncService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
running int32 // 原子标记:是否有同步正在执行
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWikiSyncService 创建同步服务
|
||||||
|
func NewWikiSyncService(db *gorm.DB) *WikiSyncService {
|
||||||
|
return &WikiSyncService{
|
||||||
|
db: db,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||||
|
reSpace = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// daysInMonth 每月天数(百度百科数据源不含 2 月 29 日,共 365 天)
|
||||||
|
var daysInMonth = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
|
||||||
|
|
||||||
|
type dayKey struct {
|
||||||
|
Month int
|
||||||
|
Day int
|
||||||
|
}
|
||||||
|
|
||||||
|
// allDays 全年 365 天
|
||||||
|
func allDays() []dayKey {
|
||||||
|
days := make([]dayKey, 0, 365)
|
||||||
|
for m := 1; m <= 12; m++ {
|
||||||
|
for d := 1; d <= daysInMonth[m-1]; d++ {
|
||||||
|
days = append(days, dayKey{Month: m, Day: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning 是否有同步任务正在执行
|
||||||
|
func (s *WikiSyncService) IsRunning() bool {
|
||||||
|
return atomic.LoadInt32(&s.running) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// coveredDaySet 已覆盖的月日集合
|
||||||
|
func (s *WikiSyncService) coveredDaySet() map[dayKey]bool {
|
||||||
|
set := make(map[dayKey]bool, 400)
|
||||||
|
type row struct {
|
||||||
|
Month int
|
||||||
|
Day int
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
s.db.Model(&model.WikiOnThisDay{}).Where("status = 1").
|
||||||
|
Select("DISTINCT month, day").Scan(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
set[dayKey{Month: r.Month, Day: r.Day}] = true
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingDays 尚未覆盖的天
|
||||||
|
func (s *WikiSyncService) missingDays() []dayKey {
|
||||||
|
covered := s.coveredDaySet()
|
||||||
|
missing := make([]dayKey, 0)
|
||||||
|
for _, d := range allDays() {
|
||||||
|
if !covered[d] {
|
||||||
|
missing = append(missing, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSync 触发一次同步(异步执行);已有任务执行中时返回 false
|
||||||
|
// full=true 全量刷新 365 天;full=false 仅补齐缺失的天
|
||||||
|
func (s *WikiSyncService) TriggerSync(trigger string, full bool) bool {
|
||||||
|
days := allDays()
|
||||||
|
if !full {
|
||||||
|
days = s.missingDays()
|
||||||
|
if len(days) == 0 {
|
||||||
|
return false // 无缺失,无需同步
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer atomic.StoreInt32(&s.running, 0)
|
||||||
|
s.runSync(trigger, days)
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScheduler 启动定时同步任务:
|
||||||
|
// - 启动时若数据库为空,延迟 10 秒后自动执行首次全量抓取
|
||||||
|
// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行
|
||||||
|
func (s *WikiSyncService) StartScheduler(intervalDays, syncHour int) {
|
||||||
|
go func() {
|
||||||
|
if missing := s.missingDays(); len(missing) > 0 {
|
||||||
|
// 数据缺失(首次部署或上次抓取中断),延迟 10 秒等服务稳定后补齐
|
||||||
|
log.Printf("wiki sync: 缺失 %d/365 天,10秒后自动补齐", len(missing))
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
s.TriggerSync("startup", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if intervalDays <= 0 {
|
||||||
|
intervalDays = 7
|
||||||
|
}
|
||||||
|
if syncHour < 0 || syncHour > 23 {
|
||||||
|
syncHour = 4
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
if now.Hour() != syncHour {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if last, ok := s.lastSuccessAt(); ok && time.Since(last) < time.Duration(intervalDays)*24*time.Hour {
|
||||||
|
continue // 未到刷新周期
|
||||||
|
}
|
||||||
|
log.Println("wiki sync: 定时任务触发全量同步")
|
||||||
|
s.TriggerSync("cron", true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间
|
||||||
|
func (s *WikiSyncService) lastSuccessAt() (time.Time, bool) {
|
||||||
|
var syncLog model.WikiSyncLog
|
||||||
|
err := s.db.Where("status IN ?", []string{"success", "partial"}).
|
||||||
|
Order("finished_at DESC").First(&syncLog).Error
|
||||||
|
if err != nil || syncLog.FinishedAt == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return *syncLog.FinishedAt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSync 同步指定日期集合(全量 365 天或缺失补齐)
|
||||||
|
// 百度百科按月返回数据,故先归并出涉及的月份,每月一次请求,再按天写入
|
||||||
|
func (s *WikiSyncService) runSync(trigger string, days []dayKey) {
|
||||||
|
syncLog := model.WikiSyncLog{
|
||||||
|
Trigger: trigger,
|
||||||
|
Status: "running",
|
||||||
|
Pages: len(days),
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
}
|
||||||
|
s.db.Create(&syncLog)
|
||||||
|
|
||||||
|
// 归并涉及的月份
|
||||||
|
monthSet := make(map[int]bool)
|
||||||
|
for _, d := range days {
|
||||||
|
monthSet[d.Month] = true
|
||||||
|
}
|
||||||
|
months := make([]int, 0, len(monthSet))
|
||||||
|
for m := range monthSet {
|
||||||
|
months = append(months, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan int, len(months))
|
||||||
|
for _, m := range months {
|
||||||
|
jobs <- m
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
var success, failed int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
workerCount := 2 // 百度百科反爬较敏感,低并发 + 间隔
|
||||||
|
for i := 0; i < workerCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for m := range jobs {
|
||||||
|
if err := s.syncOneMonth(m); err != nil {
|
||||||
|
atomic.AddInt32(&failed, 1)
|
||||||
|
log.Printf("wiki sync: %d月 失败: %v", m, err)
|
||||||
|
} else {
|
||||||
|
atomic.AddInt32(&success, 1)
|
||||||
|
}
|
||||||
|
time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发安全验证
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
if failed > 0 {
|
||||||
|
status = "partial"
|
||||||
|
}
|
||||||
|
if success == 0 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.db.Model(&syncLog).Updates(map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"success": int(success),
|
||||||
|
"failed": int(failed),
|
||||||
|
"finished_at": &now,
|
||||||
|
})
|
||||||
|
log.Printf("wiki sync: 同步完成 trigger=%s days=%d months=%d success=%d failed=%d", trigger, len(days), len(months), success, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncOneMonth 抓取某月数据并按天整体替换(死锁自动重试)
|
||||||
|
func (s *WikiSyncService) syncOneMonth(month int) error {
|
||||||
|
items, err := s.fetchMonth(month)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return fmt.Errorf("%d月解析结果为空", month)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按天分组
|
||||||
|
byDay := make(map[int][]model.WikiOnThisDay)
|
||||||
|
for _, it := range items {
|
||||||
|
byDay[it.Day] = append(byDay[it.Day], it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐天事务替换
|
||||||
|
for day, dayItems := range byDay {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
}
|
||||||
|
lastErr = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("month = ? AND day = ?", month, day).
|
||||||
|
Delete(&model.WikiOnThisDay{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&dayItems).Error
|
||||||
|
})
|
||||||
|
if lastErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !strings.Contains(lastErr.Error(), "Deadlock") && !strings.Contains(lastErr.Error(), "1213") {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("%d月%d日写入失败: %w", month, day, lastErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 百度百科数据抓取与解析 =====
|
||||||
|
|
||||||
|
// baikeEvent 百度百科「历史上的今天」单条数据
|
||||||
|
type baikeEvent struct {
|
||||||
|
Year string `json:"year"` // 年份,负数表示公元前(如 "-30")
|
||||||
|
Title string `json:"title"` // 标题(含 HTML 链接标签)
|
||||||
|
Festival string `json:"festival"` // 节假日名(当天所有条目共享,可能为空)
|
||||||
|
Link string `json:"link"` // 百科词条链接
|
||||||
|
Type string `json:"type"` // event / birth / death
|
||||||
|
Desc string `json:"desc"` // 描述(含 HTML 标签)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMonth 抓取某月「历史上的今天」数据并解析为按天的条目(失败重试)
|
||||||
|
func (s *WikiSyncService) fetchMonth(month int) ([]model.WikiOnThisDay, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://baike.baidu.com/cms/home/eventsOnHistory/%02d.json", month)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 4; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt*3) * time.Second)
|
||||||
|
}
|
||||||
|
body, err := s.doFetch(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items, err := parseBaikeMonth(body, month)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// doFetch 发起 HTTP 请求并返回响应体(带浏览器 UA 与 Referer,规避安全验证)
|
||||||
|
func (s *WikiSyncService) doFetch(apiURL string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||||||
|
req.Header.Set("Referer", "https://baike.baidu.com/calendar")
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 上限 4MB
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBaikeMonth 解析百度百科某月 JSON,输出该月全部条目
|
||||||
|
func parseBaikeMonth(body []byte, month int) ([]model.WikiOnThisDay, error) {
|
||||||
|
// 顶层结构:{"08": {"0801": [...], "0802": [...]}}
|
||||||
|
var root map[string]map[string][]baikeEvent
|
||||||
|
if err := json.Unmarshal(body, &root); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON 解析失败: %w", err)
|
||||||
|
}
|
||||||
|
monthKey := fmt.Sprintf("%02d", month)
|
||||||
|
dayMap, ok := root[monthKey]
|
||||||
|
if !ok || len(dayMap) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s月无数据", monthKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]model.WikiOnThisDay, 0, 256)
|
||||||
|
for dayKeyStr, events := range dayMap {
|
||||||
|
day, err := strconv.Atoi(dayKeyStr[2:]) // "0812" -> 12
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenFestival := make(map[string]bool)
|
||||||
|
seenContent := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
// 节假日:从 festival 字段提取(挂在每条上,去重后单独成 festival 条目)
|
||||||
|
if fest := cleanHTMLText(ev.Festival); fest != "" && !seenFestival[fest] {
|
||||||
|
seenFestival[fest] = true
|
||||||
|
items = append(items, model.WikiOnThisDay{
|
||||||
|
Month: month, Day: day, Kind: "festival", Year: 0, Content: fest, Status: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := normalizeKind(ev.Type)
|
||||||
|
if kind == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := cleanHTMLText(ev.Title)
|
||||||
|
if len([]rune(text)) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len([]rune(text)) > 500 {
|
||||||
|
text = string([]rune(text)[:500])
|
||||||
|
}
|
||||||
|
if seenContent[kind+"|"+text] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenContent[kind+"|"+text] = true
|
||||||
|
|
||||||
|
items = append(items, model.WikiOnThisDay{
|
||||||
|
Month: month,
|
||||||
|
Day: day,
|
||||||
|
Kind: kind,
|
||||||
|
Year: parseBaikeYear(ev.Year),
|
||||||
|
Content: text,
|
||||||
|
Status: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeKind 映射百度百科 type 到内部 kind
|
||||||
|
func normalizeKind(t string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
|
case "event":
|
||||||
|
return "event"
|
||||||
|
case "birth":
|
||||||
|
return "birth"
|
||||||
|
case "death":
|
||||||
|
return "death"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBaikeYear 解析年份字符串:"1912"→1912,"-30"→-30(公元前),非法→0
|
||||||
|
func parseBaikeYear(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
y, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanHTMLText 去除 HTML 标签与多余空白,输出纯文本
|
||||||
|
func cleanHTMLText(s string) string {
|
||||||
|
s = reHTMLTag.ReplaceAllString(s, "")
|
||||||
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
|
s = reSpace.ReplaceAllString(s, " ")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 取自百度百科 /cms/home/eventsOnHistory/08.json 的真实片段(8月12日/13日)
|
||||||
|
const sampleBaikeJSON = `{"08":{
|
||||||
|
"0812":[
|
||||||
|
{"year":"-30","title":"埃及艳后<a target=\"_blank\" href=\"https://baike.baidu.com/item/x\">克利奥帕特拉七世</a>逝世","festival":"","link":"https://baike.baidu.com/item/x","type":"death","desc":"克利奥帕特拉七世"},
|
||||||
|
{"year":"1848","title":"英国著名小说家<a target=\"_blank\" href=\"x\">乔治·艾略特</a>出生","festival":"","link":"x","type":"birth","desc":"x"},
|
||||||
|
{"year":"1848","title":"英国著名小说家乔治·艾略特出生","festival":"","link":"x","type":"birth","desc":"x"},
|
||||||
|
{"year":"1905","title":"挪威音乐家<a href=\"x\">某</a>指挥首演","festival":"","link":"x","type":"event","desc":"x"}
|
||||||
|
],
|
||||||
|
"0813":[
|
||||||
|
{"year":"604","title":"隋文帝<a href=\"x\">杨坚</a>逝世","festival":"国际左撇子日","link":"x","type":"death","desc":"x"},
|
||||||
|
{"year":"1926","title":"古巴革命领导人<a href=\"x\">卡斯特罗</a>出生","festival":"国际左撇子日","link":"x","type":"birth","desc":"x"},
|
||||||
|
{"year":"1910","title":"某事件<a href=\"x\">发生</a>","festival":"国际左撇子日","link":"x","type":"event","desc":"x"},
|
||||||
|
{"year":"abc","title":"年份非法的<a href=\"x\">事件</a>条目","festival":"国际左撇子日","link":"x","type":"event","desc":"x"}
|
||||||
|
]
|
||||||
|
}}`
|
||||||
|
|
||||||
|
func TestParseBaikeMonth(t *testing.T) {
|
||||||
|
items, err := parseBaikeMonth([]byte(sampleBaikeJSON), 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseBaikeMonth 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := func(kind string) int {
|
||||||
|
n := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Kind == kind {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0812: 1 death + 2 birth(重复去重后1) + 1 event = 3;0813: 1 death + 1 birth + 2 event = 4
|
||||||
|
if got := count("death"); got != 2 {
|
||||||
|
t.Errorf("death 条目数 = %d, 期望 2", got)
|
||||||
|
}
|
||||||
|
if got := count("birth"); got != 2 { // 0812 重复出生条目应去重
|
||||||
|
t.Errorf("birth 条目数 = %d, 期望 2", got)
|
||||||
|
}
|
||||||
|
if got := count("event"); got != 3 {
|
||||||
|
t.Errorf("event 条目数 = %d, 期望 3", got)
|
||||||
|
}
|
||||||
|
// 国际左撇子日只在 0813,去重后 1 条 festival
|
||||||
|
if got := count("festival"); got != 1 {
|
||||||
|
t.Errorf("festival 条目数 = %d, 期望 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验具体条目
|
||||||
|
assertItem := func(kind string, day, wantYear int, wantContent string) {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Kind == kind && it.Day == day && it.Content == wantContent {
|
||||||
|
if it.Year != wantYear {
|
||||||
|
t.Errorf("%q 年份 = %d, 期望 %d", wantContent, it.Year, wantYear)
|
||||||
|
}
|
||||||
|
if it.Month != 8 {
|
||||||
|
t.Errorf("%q 月份 = %d, 期望 8", wantContent, it.Month)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("未找到条目: kind=%s day=%d content=%q", kind, day, wantContent)
|
||||||
|
}
|
||||||
|
assertItem("death", 12, -30, "埃及艳后克利奥帕特拉七世逝世")
|
||||||
|
assertItem("birth", 12, 1848, "英国著名小说家乔治·艾略特出生")
|
||||||
|
assertItem("event", 13, 0, "年份非法的事件条目") // 非法年份应为 0
|
||||||
|
assertItem("festival", 13, 0, "国际左撇子日")
|
||||||
|
|
||||||
|
// 不应残留 HTML 标签
|
||||||
|
for _, it := range items {
|
||||||
|
if strings.Contains(it.Content, "<a") || strings.Contains(it.Content, "href") ||
|
||||||
|
strings.Contains(it.Content, "</a>") {
|
||||||
|
t.Errorf("条目残留 HTML 标签: %q", it.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBaikeYear(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"1912": 1912,
|
||||||
|
"-30": -30,
|
||||||
|
"0": 0,
|
||||||
|
"abc": 0,
|
||||||
|
"": 0,
|
||||||
|
" 77 ": 77,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := parseBaikeYear(in); got != want {
|
||||||
|
t.Errorf("parseBaikeYear(%q) = %d, 期望 %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKind(t *testing.T) {
|
||||||
|
if normalizeKind("event") != "event" || normalizeKind("birth") != "birth" ||
|
||||||
|
normalizeKind("death") != "death" || normalizeKind("other") != "" ||
|
||||||
|
normalizeKind(" EVENT ") != "event" {
|
||||||
|
t.Error("normalizeKind 映射异常")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanHTMLText(t *testing.T) {
|
||||||
|
in := `埃及艳后<a target="_blank" href="x">克利奥帕特拉七世</a> 逝世 test`
|
||||||
|
want := "埃及艳后克利奥帕特拉七世 逝世 test"
|
||||||
|
if got := cleanHTMLText(in); got != want {
|
||||||
|
t.Errorf("cleanHTMLText = %q, 期望 %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,15 +97,21 @@ func (s *WishService) CreateWish(wish *model.Wish) error {
|
|||||||
return s.db.Create(wish).Error
|
return s.db.Create(wish).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTreeWishes 获取指定树的许愿列表
|
// GetTreeWishes 获取指定树的许愿列表(最新100条,标记顶级位置)
|
||||||
func (s *WishService) GetTreeWishes(treeID uint) ([]*model.Wish, error) {
|
func (s *WishService) GetTreeWishes(treeID uint) ([]*model.Wish, error) {
|
||||||
var wishes []*model.Wish
|
var wishes []*model.Wish
|
||||||
if err := s.db.Where("tree_id = ? AND status = 1", treeID).
|
if err := s.db.Where("tree_id = ? AND status = 1", treeID).
|
||||||
Order("position DESC, created_at DESC").
|
Order("created_at DESC").
|
||||||
Limit(100).
|
Limit(100).
|
||||||
Find(&wishes).Error; err != nil {
|
Find(&wishes).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算顶级位置:高位置付费许愿且购买后24小时内
|
||||||
|
for _, w := range wishes {
|
||||||
|
w.IsTop = w.Position >= 100 && time.Since(w.CreatedAt) <= 24*time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
return wishes, nil
|
return wishes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS wish_trees (
|
|||||||
name VARCHAR(50) NOT NULL COMMENT '名称',
|
name VARCHAR(50) NOT NULL COMMENT '名称',
|
||||||
description VARCHAR(255) DEFAULT '' COMMENT '描述',
|
description VARCHAR(255) DEFAULT '' COMMENT '描述',
|
||||||
type VARCHAR(20) DEFAULT 'pine' COMMENT '树类型 pine:松树 sakura:樱花 bamboo:竹子',
|
type VARCHAR(20) DEFAULT 'pine' COMMENT '树类型 pine:松树 sakura:樱花 bamboo:竹子',
|
||||||
|
image VARCHAR(255) DEFAULT '' COMMENT '背景图地址',
|
||||||
max_wishes INT DEFAULT 100 COMMENT '最大许愿条数',
|
max_wishes INT DEFAULT 100 COMMENT '最大许愿条数',
|
||||||
sort INT DEFAULT 0 COMMENT '排序',
|
sort INT DEFAULT 0 COMMENT '排序',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态',
|
status TINYINT DEFAULT 1 COMMENT '状态',
|
||||||
@@ -96,10 +97,10 @@ CREATE TABLE IF NOT EXISTS wish_products (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿商品表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿商品表';
|
||||||
|
|
||||||
-- 插入默认许愿树
|
-- 插入默认许愿树
|
||||||
INSERT IGNORE INTO wish_trees (id, name, description, type, max_wishes, sort) VALUES
|
INSERT IGNORE INTO wish_trees (id, name, description, type, image, max_wishes, sort) VALUES
|
||||||
(1, '祈福树', '许下美好愿望,祈福平安顺遂', 'pine', 100, 0),
|
(1, '祈福树', '许下美好愿望,祈福平安顺遂', 'pine', '/images/trees/tree-1.jpg', 100, 0),
|
||||||
(2, '姻缘树', '祈求姻缘美满,爱情甜蜜', 'sakura', 50, 1),
|
(2, '姻缘树', '祈求姻缘美满,爱情甜蜜', 'sakura', '/images/trees/tree-1.jpg', 50, 1),
|
||||||
(3, '事业树', '祈愿事业顺利,步步高升', 'bamboo', 80, 2);
|
(3, '事业树', '祈愿事业顺利,步步高升', 'bamboo', '/images/trees/tree-1.jpg', 80, 2);
|
||||||
|
|
||||||
-- 插入默认许愿商品
|
-- 插入默认许愿商品
|
||||||
INSERT IGNORE INTO wish_products (id, name, description, price, duration, position) VALUES
|
INSERT IGNORE INTO wish_products (id, name, description, price, duration, position) VALUES
|
||||||
|
|||||||
+14
-5
@@ -5,24 +5,33 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ .title }} - 祈福小助手管理后台</title>
|
<title>{{ .title }} - 祈福小助手管理后台</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
||||||
<script src="https://unpkg.com/@inertiajs/vue3@1.0.0/dist/index.umd.js"></script>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) -->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"vue": "https://esm.sh/vue@3.4.21/dist/vue.esm-browser.prod.js",
|
||||||
|
"@inertiajs/vue3": "https://esm.sh/@inertiajs/vue3@1.0.0?bundle&external=vue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div id="app" data-page='{"component":"{{ .page }}","props":{},"url":"{{ .url }}","version":""}'></div>
|
<div id="app" data-page='{"component":"{{ .page }}","props":{},"url":"{{ .url }}","version":""}'></div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
const { createApp, h } = Vue;
|
import { createApp, h } from 'vue';
|
||||||
const { createInertiaApp } = Inertia;
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
resolve: name => {
|
resolve: name => {
|
||||||
const pages = {
|
const pages = {
|
||||||
|
login: () => import('/static/js/pages/Login.js'),
|
||||||
dashboard: () => import('/static/js/pages/Dashboard.js'),
|
dashboard: () => import('/static/js/pages/Dashboard.js'),
|
||||||
users: () => import('/static/js/pages/Users.js'),
|
users: () => import('/static/js/pages/Users.js'),
|
||||||
orders: () => import('/static/js/pages/Orders.js'),
|
orders: () => import('/static/js/pages/Orders.js'),
|
||||||
wishes: () => import('/static/js/pages/Wishes.js'),
|
wishes: () => import('/static/js/pages/Wishes.js'),
|
||||||
|
wiki: () => import('/static/js/pages/Wiki.js'),
|
||||||
settings: () => import('/static/js/pages/Settings.js'),
|
settings: () => import('/static/js/pages/Settings.js'),
|
||||||
};
|
};
|
||||||
return pages[name] ? pages[name]() : pages.dashboard();
|
return pages[name] ? pages[name]() : pages.dashboard();
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default {
|
|||||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
许愿管理
|
许愿管理
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
系统设置
|
系统设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Login 管理员登录页
|
||||||
|
export default {
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen bg-gray-100 flex items-center justify-center px-4">
|
||||||
|
<div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-red-600">祈福小助手</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">管理后台登录</p>
|
||||||
|
<p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">管理密码</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="请输入 ADMIN_PASSWORD"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="mb-4 text-sm text-red-600">{{ error }}</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !password"
|
||||||
|
class="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登 录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
password: '',
|
||||||
|
loading: false,
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submit() {
|
||||||
|
if (!this.password || this.loading) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: this.password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code === 0) {
|
||||||
|
// Cookie 已由服务端写入,跳回原本要访问的页面
|
||||||
|
window.location.href = window.location.pathname === '/admin/login'
|
||||||
|
? '/admin/'
|
||||||
|
: window.location.pathname;
|
||||||
|
} else {
|
||||||
|
this.error = data.msg || '登录失败';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = '网络错误,请重试';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,9 @@ export default {
|
|||||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
许愿管理
|
许愿管理
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
系统设置
|
系统设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default {
|
|||||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
许愿管理
|
许愿管理
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
系统设置
|
系统设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default {
|
|||||||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
许愿管理
|
许愿管理
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
系统设置
|
系统设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
|
||||||
|
export default {
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen bg-gray-100">
|
||||||
|
<nav class="bg-white shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0 flex items-center">
|
||||||
|
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||||
|
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
仪表盘
|
||||||
|
</a>
|
||||||
|
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
用户管理
|
||||||
|
</a>
|
||||||
|
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
订单管理
|
||||||
|
</a>
|
||||||
|
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
许愿管理
|
||||||
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
系统设置
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- 标签页切换 -->
|
||||||
|
<div class="px-4 sm:px-0 mb-4">
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button @click="tab = 'entries'" :class="tab === 'entries' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">百科条目</button>
|
||||||
|
<button @click="tab = 'sync'" :class="tab === 'sync' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">历史上的今天(维基同步)</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 百科条目管理 -->
|
||||||
|
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
|
||||||
|
<!-- 节气详情同步(百度百科) -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-4">
|
||||||
|
<div class="px-4 py-4 sm:px-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900">节气详情(百度百科)</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-0.5">
|
||||||
|
已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }}
|
||||||
|
<span v-if="termSync.lastSync && termSync.lastSync.startedAt"> | 最近同步:{{ formatTime(termSync.lastSync.startedAt) }}</span>
|
||||||
|
<span v-if="termSync.lastSync && termSync.lastSync.status" :class="termSync.lastSync.status === 'success' ? 'text-green-600' : 'text-yellow-600'"> | {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="loadTermSyncStatus" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新</button>
|
||||||
|
<button @click="triggerTermSync" :disabled="termSync.running || termSyncing" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fas fa-sync mr-1" :class="termSync.running ? 'fa-spin' : ''"></i>{{ termSync.running ? '同步中...' : '同步节气详情' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">百科条目(共 {{ entries.length }} 条)</h2>
|
||||||
|
<button @click="openEditor()" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
|
<i class="fas fa-plus mr-1"></i>新增条目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<select v-model="filterCat" @change="loadEntries" class="block rounded-md border-gray-300 border px-3 py-2 text-sm">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="filterKeyword" @keyup.enter="loadEntries" placeholder="搜索标题/内容..." class="block rounded-md border-gray-300 border px-3 py-2 text-sm w-64"/>
|
||||||
|
<button @click="loadEntries" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">搜索</button>
|
||||||
|
</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 font-medium text-gray-500 uppercase">ID</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">分类</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">简介</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">排序</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="e in entries" :key="e.id">
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ e.id }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm whitespace-nowrap">{{ catName(e.category) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-gray-900 whitespace-nowrap">{{ e.title }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">{{ e.brief }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ e.sort }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span :class="e.status === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">{{ e.status === 1 ? '显示' : '隐藏' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
|
<button @click="openEditor(e)" class="text-red-600 hover:text-red-800 mr-3">编辑</button>
|
||||||
|
<button @click="removeEntry(e)" class="text-gray-400 hover:text-red-600">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="entries.length === 0">
|
||||||
|
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-400">暂无条目</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 历史上的今天:维基百科同步 -->
|
||||||
|
<div class="px-4 py-2 sm:px-0" v-if="tab === 'sync'">
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">维基百科数据同步</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">数据来源:中文维基百科「X月X日」页面(共 366 页,含大事记/出生/逝世/节假日习俗)。服务启动时若数据缺失会自动抓取;之后按配置间隔(默认 7 天)定时增量刷新。</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-500">日期覆盖</div>
|
||||||
|
<div class="text-2xl font-bold">{{ syncStatus.totalDays || 0 }} / 366 天</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-500">条目总数</div>
|
||||||
|
<div class="text-2xl font-bold">{{ syncStatus.totalItems || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-500">同步状态</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
<span v-if="syncStatus.running" class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>进行中</span>
|
||||||
|
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'success'" class="text-green-600">已完成</span>
|
||||||
|
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'partial'" class="text-yellow-600">部分失败</span>
|
||||||
|
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'failed'" class="text-red-600">失败</span>
|
||||||
|
<span v-else class="text-gray-400">未同步</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="syncStatus.lastSync" class="text-sm text-gray-600 mb-4 bg-gray-50 rounded-lg p-4">
|
||||||
|
<div>最近同步:{{ formatTime(syncStatus.lastSync.startedAt) }} ~ {{ syncStatus.lastSync.finishedAt ? formatTime(syncStatus.lastSync.finishedAt) : '进行中' }}</div>
|
||||||
|
<div class="mt-1">触发方式:{{ triggerName(syncStatus.lastSync.trigger) }} | 成功 {{ syncStatus.lastSync.success }} 页 / 失败 {{ syncStatus.lastSync.failed }} 页</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="triggerSync" :disabled="syncStatus.running || syncing" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<i class="fas fa-sync mr-2" :class="syncStatus.running ? 'fa-spin' : ''"></i>{{ syncStatus.running ? '同步进行中(约 3-5 分钟)' : '立即全量同步' }}
|
||||||
|
</button>
|
||||||
|
<button @click="loadSyncStatus" class="ml-3 px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新状态</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 条目编辑弹窗 -->
|
||||||
|
<div v-if="editor.visible" class="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75" @click="editor.visible = false"></div>
|
||||||
|
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ editor.id ? '编辑条目' : '新增条目' }}</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">分类</label>
|
||||||
|
<select v-model="editor.form.category" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||||||
|
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||||||
|
<input v-model="editor.form.title" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">简介(列表折叠时显示)</label>
|
||||||
|
<input v-model="editor.form.brief" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label>
|
||||||
|
<textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">配图 URL</label>
|
||||||
|
<input v-model="editor.form.image" placeholder="https://..." class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||||
|
<div v-if="editor.form.image" class="mt-2"><img :src="editor.form.image" class="h-20 rounded border" alt="配图预览"/></div>
|
||||||
|
</div>
|
||||||
|
<!-- 节气扩展字段(仅节气分类显示) -->
|
||||||
|
<div v-if="editor.form.category === 'term'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">节气扩展信息</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div v-for="f in termExtraFields" :key="f.key">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">{{ f.name }}</label>
|
||||||
|
<input v-model="editor.extra[f.key]" class="block w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label>
|
||||||
|
<input type="number" v-model.number="editor.form.sort" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||||||
|
<select v-model.number="editor.form.status" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||||||
|
<option :value="1">显示</option>
|
||||||
|
<option :value="0">隐藏</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end space-x-3">
|
||||||
|
<button @click="editor.visible = false" class="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">取消</button>
|
||||||
|
<button @click="saveEntry" :disabled="saving" class="px-4 py-2 border border-transparent rounded-md text-sm text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">{{ saving ? '保存中...' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tab: 'entries',
|
||||||
|
categories: [
|
||||||
|
{ key: 'term', name: '二十四节气' },
|
||||||
|
{ key: 'lunarFest', name: '农历节日' },
|
||||||
|
{ key: 'solarFest', name: '公历节日' },
|
||||||
|
{ key: 'ganzhi', name: '干支生肖' },
|
||||||
|
{ key: 'termExplain', name: '黄历术语' },
|
||||||
|
{ key: 'other', name: '其他' },
|
||||||
|
],
|
||||||
|
entries: [],
|
||||||
|
filterCat: '',
|
||||||
|
filterKeyword: '',
|
||||||
|
editor: {
|
||||||
|
visible: false,
|
||||||
|
id: null,
|
||||||
|
form: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 },
|
||||||
|
extra: {},
|
||||||
|
},
|
||||||
|
termExtraFields: [
|
||||||
|
{ key: 'alias', name: '别名' },
|
||||||
|
{ key: 'english', name: '外文名' },
|
||||||
|
{ key: 'meaning', name: '涵义' },
|
||||||
|
{ key: 'solarDate', name: '公历时间' },
|
||||||
|
{ key: 'ecliptic', name: '黄道位置' },
|
||||||
|
{ key: 'climate', name: '气候特点' },
|
||||||
|
{ key: 'phenology', name: '物候现象' },
|
||||||
|
{ key: 'farming', name: '农事活动' },
|
||||||
|
{ key: 'customs', name: '传统习俗' },
|
||||||
|
{ key: 'health', name: '起居养生' },
|
||||||
|
{ key: 'flower', name: '花信' },
|
||||||
|
],
|
||||||
|
saving: false,
|
||||||
|
syncing: false,
|
||||||
|
syncStatus: {},
|
||||||
|
syncTimer: null,
|
||||||
|
termSync: {},
|
||||||
|
termSyncing: false,
|
||||||
|
termSyncTimer: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadEntries();
|
||||||
|
this.loadSyncStatus();
|
||||||
|
this.loadTermSyncStatus();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.syncTimer) clearInterval(this.syncTimer);
|
||||||
|
if (this.termSyncTimer) clearInterval(this.termSyncTimer);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
catName(key) {
|
||||||
|
const c = this.categories.find(x => x.key === key);
|
||||||
|
return c ? c.name : key;
|
||||||
|
},
|
||||||
|
formatTime(t) {
|
||||||
|
if (!t) return '';
|
||||||
|
return new Date(t).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
},
|
||||||
|
triggerName(t) {
|
||||||
|
return { startup: '启动自动', cron: '定时任务', admin: '手动触发' }[t] || t;
|
||||||
|
},
|
||||||
|
async api(url, options = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
alert('登录已过期,请重新登录');
|
||||||
|
window.location.href = '/admin';
|
||||||
|
throw new Error('未授权');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code !== 0) throw new Error(data.msg || '请求失败');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
async loadEntries() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.filterCat) params.set('category', this.filterCat);
|
||||||
|
if (this.filterKeyword) params.set('keyword', this.filterKeyword);
|
||||||
|
const data = await this.api('/admin/api/wiki/entries?' + params.toString());
|
||||||
|
this.entries = data.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openEditor(entry) {
|
||||||
|
this.editor.id = entry ? entry.id : null;
|
||||||
|
this.editor.form = entry
|
||||||
|
? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, image: entry.image || '', sort: entry.sort, status: entry.status }
|
||||||
|
: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 };
|
||||||
|
let extra = {};
|
||||||
|
if (entry && entry.extra) {
|
||||||
|
try { extra = JSON.parse(entry.extra); } catch (e) { extra = {}; }
|
||||||
|
}
|
||||||
|
this.editor.extra = extra;
|
||||||
|
this.editor.visible = true;
|
||||||
|
},
|
||||||
|
async saveEntry() {
|
||||||
|
const f = this.editor.form;
|
||||||
|
if (!f.title.trim()) { alert('标题不能为空'); return; }
|
||||||
|
// 节气分类:把 extra 对象序列化为 JSON 字符串
|
||||||
|
const payload = { ...f };
|
||||||
|
if (f.category === 'term') {
|
||||||
|
const extra = {};
|
||||||
|
this.termExtraFields.forEach(fd => {
|
||||||
|
const v = (this.editor.extra[fd.key] || '').trim();
|
||||||
|
if (v) extra[fd.key] = v;
|
||||||
|
});
|
||||||
|
payload.extra = JSON.stringify(extra);
|
||||||
|
} else {
|
||||||
|
payload.extra = '';
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
if (this.editor.id) {
|
||||||
|
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
} else {
|
||||||
|
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
this.editor.visible = false;
|
||||||
|
this.loadEntries();
|
||||||
|
this.loadTermSyncStatus();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeEntry(entry) {
|
||||||
|
if (!confirm(`确认删除条目「${entry.title}」?`)) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/admin/api/wiki/entries/${entry.id}`, { method: 'DELETE' });
|
||||||
|
this.loadEntries();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadSyncStatus() {
|
||||||
|
try {
|
||||||
|
this.syncStatus = await this.api('/admin/api/wiki/sync-status');
|
||||||
|
// 同步进行中时每 10 秒自动刷新
|
||||||
|
if (this.syncStatus.running && !this.syncTimer) {
|
||||||
|
this.syncTimer = setInterval(() => this.loadSyncStatus(), 10000);
|
||||||
|
} else if (!this.syncStatus.running && this.syncTimer) {
|
||||||
|
clearInterval(this.syncTimer);
|
||||||
|
this.syncTimer = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async triggerSync() {
|
||||||
|
if (!confirm('全量同步将抓取 366 个维基百科页面(约 3-5 分钟),确认执行?')) return;
|
||||||
|
this.syncing = true;
|
||||||
|
try {
|
||||||
|
await this.api('/admin/api/wiki/sync', { method: 'POST' });
|
||||||
|
this.loadSyncStatus();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadTermSyncStatus() {
|
||||||
|
try {
|
||||||
|
this.termSync = await this.api('/admin/api/wiki/term-sync-status');
|
||||||
|
if (this.termSync.running && !this.termSyncTimer) {
|
||||||
|
this.termSyncTimer = setInterval(() => this.loadTermSyncStatus(), 5000);
|
||||||
|
} else if (!this.termSync.running && this.termSyncTimer) {
|
||||||
|
clearInterval(this.termSyncTimer);
|
||||||
|
this.termSyncTimer = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async triggerTermSync() {
|
||||||
|
if (!confirm('将从百度百科抓取 24 节气的详细图文(约 30 秒),确认执行?')) return;
|
||||||
|
this.termSyncing = true;
|
||||||
|
try {
|
||||||
|
await this.api('/admin/api/wiki/term-sync', { method: 'POST' });
|
||||||
|
this.loadTermSyncStatus();
|
||||||
|
setTimeout(() => this.loadEntries(), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
this.termSyncing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export default {
|
|||||||
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
许愿管理
|
许愿管理
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
|
百科管理
|
||||||
|
</a>
|
||||||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||||
系统设置
|
系统设置
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user