Author SHA1 Message Date
gouki 3cc3edeef9 chore(server): add sqlite driver to go.mod/go.sum
Build and Publish Server / build (push) Successful in 59s
2026-08-10 17:43:09 +00:00
gouki 8b5bfe8385 feat(server): support SQLite via DB_TYPE env var
Build and Publish Server / build (push) Failing after 43s
2026-08-10 17:28:45 +00:00
gouki a29328f35b fix(wish-tree): 弹幕一行一条排队播放,不再重叠
Publish Mini Program Dev Version / publish (push) Successful in 1m4s
- 顶部滚动行上移紧贴置顶轮播(16%→12%)
- 树桩3行改为68/73/78%,更紧凑
- 核心修复重叠:弃用CSS无限循环+负delay(同屏多条),
  改为JS定时器按行调度,同一行一条飘完再出下一条,循环播放
- 弹幕动画改为只播一次,key含时间戳保证切换时重新触发动画
- 页面onHide/onUnload停止定时器,onShow恢复
2026-08-10 13:27:19 +00:00
gouki 87f5fa2478 refactor(mini): 个人中心改为登录+二级菜单结构
Publish Mini Program Dev Version / publish (push) Successful in 1m8s
- 个人中心仅保留登录管理、我的数据/功能菜单与关于信息
- 新增偏好设置二级页 preferences(原偏好设置统一收敛至此)
- 新增我的收藏二级页 bookmarks,支持删除与跳转日详情
- 生辰管理拆分为二级页 birth-profiles,未登录时提示引导登录
- 八字页入口改为直达生辰管理页;修复 fortune 非 tabBar 页误用 switchTab
2026-08-10 11:52:15 +00:00
gouki f7d88622e0 tweak(wish-tree): 弹幕改为4行布局
Publish Mini Program Dev Version / publish (push) Successful in 1m10s
- 树桩处3行(64/71/78%),不再遮挡上方树冠便签
- 置顶轮播下方新增1行滚动弹幕(16%)
- 同行统一慢速(树桩32s/顶部26s飘完全屏),按条数均匀错开起始位置,互相不盖
2026-08-10 08:45:38 +00:00
gouki ac2c99c3bc fix(ci): Alpine容器补装libstdc++/libgcc——musl版node的运行时依赖
Publish Mini Program Dev Version / publish (push) Successful in 50s
sha256校验已通过(下载完整),但HKRunner的Alpine精简容器缺
libstdc++.so.6/libgcc_s.so.1——node musl构建是动态链接这两个库的。
缺失时用apk补装,装不上给warning
2026-08-10 00:12:38 +00:00
gouki 4efdd20ff7 fix(ci): 真正根因——Node下载截断,改用sha256硬校验+镜像源优先
Publish Mini Program Dev Version / publish (push) Failing after 39s
根因:unofficial-builds.nodejs.org 在亚洲带宽极低,wget --timeout
只限单次读取,慢速断流仍返回成功,CI拿到截断的gzip(15MB/55MB),
解压出损坏二进制报 symbol not found(并非glibc/musl混用)。

修复:
1. sha256 硬校验拦截一切假文件(musl:8d96de02... glibc:e05a4d65...)
2. musl 版优先 npmmirror 镜像(实测与官方sha256一致),unofficial 后备
3. 循环换源重试,全部失败才报错
4. wget 改用 busybox 兼容短参数 -t/-T
2026-08-10 00:01:14 +00:00
gouki 297747041a fix(ci): Node下载增加npmmirror镜像源+gzip校验+二进制验证
Publish Mini Program Dev Version / publish (push) Failing after 11s
1826/1827连续失败:hk runner下载musl版node时拿到错误产物
(glibc版node在musl环境报libstdc++.so.6缺失)。
加固:musl下载增加npmmirror镜像fallback;下载后校验gzip魔数;
解压后验证node可执行,失败则清除缓存避免污染后续run
2026-08-09 23:47:23 +00:00
gouki 50975e0627 feat(bazi): 八字页重构——空态示例排盘+字段术语表+文化声明,生辰档案绑定
Publish Mini Program Dev Version / publish (push) Failing after 11s
Build and Publish Server / build (push) Successful in 2m55s
- 八字页:未绑定生辰时展示虚构示例排盘(1990-08-08 10:30男)与15条字段说明,
  附传统文化声明文案;已绑定时支持多档案切换,排盘增强(十神/藏干/纳音/五行配色/命盘概览)
- 个人中心:新增生辰管理卡片,每用户免费绑定3个出生年月;登录改为真实微信登录
- 服务端:新增 BirthProfile 模型与 /api/user/birth-profiles 增删查接口,AutoMigrate 建表
- 前端:新增 birth-profile.js 本地存储优先+登录后云端双向同步
- 修复:个人中心 tabBar 页面入口误用 navigateTo 改为 switchTab
2026-08-09 23:21:58 +00:00
gouki 013e7ab99a tweak(wish-tree): 挂树便签减为30条,其余走弹幕
Publish Mini Program Dev Version / publish (push) Failing after 11s
- MAX_HANG 从50降为30,树冠更疏朗参差
- 挂载点从19个扩到31个,30条便签各占一位不重叠
2026-08-09 22:46:49 +00:00
gouki 4b003fad39 fix(ci): 清理Node工具链缓存前整目录删除,避免musl环境残留glibc版node
Publish Mini Program Dev Version / publish (push) Successful in 3m2s
hk runner(Alpine/musl)缓存目录残留glibc版node,busybox mv合并目录
导致新下载的musl版未生效,node启动报symbol not found(exit 127)。
改为rm -rf整个TOOLDIR后重建,确保干净下载
2026-08-09 22:10:34 +00:00
gouki b971a34f7f fix(home): 宜忌恢复点击放大+生肖移到日历/月历切换按钮旁边
Publish Mini Program Dev Version / publish (push) Failing after 11s
1. 大框内的宜忌区域加回 bindtap=openZoom(data-type=yiji),点击可全屏查看放大宜忌
2. 生肖(马年)从导航栏右侧移到左侧切换按钮旁边
3. 标题改为绝对定位居中,保持导航栏对称
2026-08-09 21:46:03 +00:00
gouki fe5bed0668 feat(wish-tree): 许愿树页面微调七项
Publish Mini Program Dev Version / publish (push) Failing after 11s
Build and Publish Server / build (push) Successful in 2m54s
1. 背景图 aspectFill 撑满全屏,挂载点坐标按裁剪比例重算,便签不再落在图外
2. 写下心愿按钮单行不换行,缩小尺寸
3. 移除页面内许愿树标题/数量/最近心愿,点击便签弹出便签式详情卡
4. 默认请求100条:最新50条挂树,其余以弹幕形式缓缓飘过
5. MAKE A WISH 下方增加顶级位置轮播(购买后24小时内展示),点击弹出金色便签详情,与普通便签区分
6. 多棵树分屏轮播(swiper),背景图从服务器获取,带指示点
7. 后端:WishTree新增image字段,Wish新增isTop运行时标记,图片用go:embed内嵌随二进制部署,/images路由提供访问
2026-08-09 20:58:32 +00:00
gouki f5c8d06fbe fix(ci): 修复发布版本被 GITEA_ENV 残留值污染(构建 1.1.48 却发布 1.0.48)
Build and Publish Server / build (push) Successful in 1m27s
- Build 步骤不再向 GITEA_ENV 导出 SERVER_VERSION(跨步骤持久化会残留旧值)
- Publish 步骤显式 unset SERVER_VERSION 并自行计算版本号
2026-08-09 20:24:09 +00:00
gouki fb1bd57af0 feat(ci): 发布后自动清理旧版本,server-v* Release 仅保留最近 10 个
Build and Publish Server / build (push) Successful in 1m24s
- 超出部分连同附件与 tag 一并删除,防止磁盘被部署包占满
- 纯 grep/sed 解析,不依赖 python3/jq;已对真实 API 验证提取逻辑
2026-08-09 20:14:31 +00:00
gouki a6bdfee548 chore(ci): 后端版本基线提升为 1.1(与小程序版本段对齐)
Build and Publish Server / build (push) Successful in 2m57s
2026-08-09 18:58:33 +00:00
gouki 23442017b3 fix(ci): Alpine/musl runner 自动改用 Node musl 构建(glibc 版无法执行),缓存校验加运行测试
Publish Mini Program Dev Version / publish (push) Successful in 3m8s
2026-08-09 18:49:06 +00:00
gouki 320231808b fix(mini): 修复版本号被 MINIAPP_VERSION_OVERRIDE=1.0.1 永久冻结
Publish Mini Program Dev Version / publish (push) Failing after 9s
- 删除冻结版本变量及工作流对它的引用(遗留的临时锁定变量)
- 版本编码改为保留 package.json 的 major.minor 基线,构建编号映射 patch(单调递增)
- 基线版本提升为 1.1.0,下次上传即为 1.1.4x(此前反复上传 1.0.1)
2026-08-09 18:36:19 +00:00
gouki fdc11bf529 fix(ci): 发布令牌改用仓库 Secret(内建 GITEA_TOKEN 未注入),补充 API 响应诊断
Build and Publish Server / build (push) Successful in 3m2s
2026-08-09 18:17:54 +00:00
gouki d73bbf53cf fix(ci): 精简 runner 环境自适应安装 curl(发布 release 依赖)
Build and Publish Server / build (push) Failing after 2m55s
2026-08-09 18:06:06 +00:00
gouki ffac513b4a fix(ci): checkout 改用 gitea.* 表达式传参(runner 未注入 GITEA_* 默认环境变量)
Build and Publish Server / build (push) Failing after 1m22s
Publish Mini Program Dev Version / publish (push) Successful in 2m52s
2026-08-09 17:52:50 +00:00
gouki fdbf8307bb fix(ci): 兼容精简 runner(Alpine)环境
Build and Publish Server / build (push) Failing after 0s
Publish Mini Program Dev Version / publish (push) Failing after 3s
- checkout 改为原生 git 浅克隆(action-checkout 依赖 node,Alpine runner 无)
- 发布 release 改用 grep/sed 解析 JSON(不依赖 python3)
- 小程序密钥 PEM 规整改用 node 实现
- 通知步骤增加容错(失败不阻断主流程)
2026-08-09 17:44:47 +00:00
gouki e92b739cb6 fix(ci): runs-on 改用标准标签 ubuntu-latest(hk/bt-runner 均可接单)
Build and Publish Server / build (push) Failing after 1s
Publish Mini Program Dev Version / publish (push) Failing after 2m23s
2026-08-09 17:26:11 +00:00
gouki b507d6c154 feat(ci): 迁移到 hk runner + Release 产物分发,解除对 doc79 runner 的依赖
Publish Mini Program Dev Version / publish (push) Canceled after 0s
Build and Publish Server / build (push) Canceled after 0s
- 两个工作流 runs-on 改为 hk(在线);Go/Node 工具链自包含下载+工作区缓存
- 后端产物改为发布 Gitea Release 附件(server-v<版本>),与 runner 位置解耦
- deploy-watch.sh 重写为从 Release 拉取:匿名/令牌 HTTPS 下载、包结构预检、
  按 release id 幂等、失败下轮重试
- 移除对 /ci-artifacts 本机目录的依赖(原设计要求 runner 与生产同机)
- PULL_DEPLOY.md 架构图同步更新
2026-08-09 17:17:23 +00:00
gouki fc7a30ad9d refactor(deploy): 部署方案改为 1Panel 原生集成
- 移除系统 nginx 配置(1Panel 用内置 OpenResty,系统 nginx 不适用)
- 站点反代改用 1Panel 网站-反向代理;容器改用 1Panel Compose 编排
- 定时触发由 systemd timer 改为 1Panel 计划任务(日志面板可视)
- 脚本上传改用 1Panel 文件管理器,PULL_DEPLOY.md 全步骤 1Panel 化
- deploy.sh/deploy-watch.sh 核心逻辑不变(原子切换+健康检查+回滚)
2026-08-09 10:37:06 +00:00
gouki 9e93198ef2 docs(deploy): 移除 CORS_ORIGINS 配置建议,避免小程序跨域头缺失 2026-08-09 00:39:00 +00:00
gouki 0deb5ba7b6 feat(deploy): doc79 同机部署 lunar.neatcn.com 配置入库
- 新增 nginx 反代配置(HTTPS + 本机 8080,含 1Panel 建站替代说明)
- 新增 docker-compose.yml(端口仅绑定 127.0.0.1,env_file 注入生产变量)
- PULL_DEPLOY.md 重写为完整一次性安装指南:域名/证书/微信合法域名/
  生产环境变量模板/容器/脚本/timer/验证清单
- AUTO_DEPLOY_1PANEL.md 标记废弃(SSH 手工方式违反安全红线)
2026-08-09 00:34:12 +00:00
45 changed files with 2683 additions and 964 deletions
+20
View File
@@ -0,0 +1,20 @@
# 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
ports:
- "127.0.0.1:8080:8080" # 只监听本机,对外由 nginx 反代 lunar.neatcn.com
env_file:
- /opt/lunar/production/.env
restart: unless-stopped
command: /app/bin/server
+4
View File
@@ -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
View File
@@ -1,68 +1,125 @@
# 拉取式部署配置(一次性) # doc79 同机拉取式部署配置(1Panel 原生方式,一次性)
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。 > 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由服务器本地的 watcher 监听 CI 产物目录自动完成。本文档的所有命令由**管理员本人**在服务器上执行一次。 > 部署由服务器本地定时任务监听 CI 产物目录自动完成。
> 本文档所有操作均通过 **1Panel 网页界面**或管理员本人的终端完成,不依赖 AI 远程执行。
## 工作原理 ## 架构
``` ```
push main → Gitea Actions 构建 产物写入 /ci-artifactsrunner 与宿主同机) push main → Gitea Actionshk 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 能力内,日志、重启、证书都在一个面板里可见可控,
避免绕过平台管理造成"幽灵配置"。
+74 -22
View File
@@ -1,46 +1,98 @@
#!/bin/bash #!/bin/bash
# 拉取式部署 watcher - 监听 CI 产物目录,发现新版本自动部署 # 拉取式部署 watcher - 从 Gitea Release 拉取最新部署包并自动部署
# 设计原则: # 设计原则:
# - CI 只负责构建并把产物写入 /ci-artifactsrunner 与本机同宿主) # - CI 在任意 runnerhk 等)构建,产物以 Release 附件发布到 Gitea
# - 部署动作只由服务器本地的本脚本触发,任何 AI/CI 均不通过 SSH 操作服务器 # - 本脚本由服务器本地定时任务(1Panel 计划任务)每分钟调用,通过 HTTPS 拉取,
# 安装方式见 .gitea/docs/PULL_DEPLOY.md(由管理员在服务器上手工执行一次) # 任何 AI/CI 均不通过 SSH 操作服务器
# 安装方式见 .gitea/docs/PULL_DEPLOY.md1Panel 原生方式,一次性配置)
#
# 可选环境变量(建议写入 /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
+95 -31
View File
@@ -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 # 精简 runnerAlpine)无 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"
# 探测 libcAlpine(musl) 用 unofficial musl 构建,其余用官方 glibc 构建(tar.gzbusybox 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_sAlpine 精简容器缺失时补装
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()
+129 -30
View File
@@ -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 # 精简 runnerAlpine)无 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: |
# 精简 runnerAlpine 等)可能未预装 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 注入版本信息;目标平台为 doc79linux/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_VERSIONGITEA_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()
+3
View File
@@ -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"
+10 -7
View File
@@ -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
View File
@@ -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": {
+127 -52
View File
@@ -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,118 @@ 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' });
} }
}); });
+100 -47
View File
@@ -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>
<!-- 底部操作 -->
<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>
+200 -23
View File
@@ -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;
}
+108
View File
@@ -0,0 +1,108 @@
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();
}
});
@@ -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;
}
+28
View File
@@ -0,0 +1,28 @@
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}` });
}
});
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "我的收藏",
"enablePullDownRefresh": false
}
+20
View File
@@ -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>
+24
View File
@@ -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;
}
+5 -4
View File
@@ -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>
@@ -64,8 +65,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">
+16 -1
View File
@@ -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 {
+54
View File
@@ -0,0 +1,54 @@
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();
}
});
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "偏好设置",
"enablePullDownRefresh": false
}
+38
View File
@@ -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>
+32
View File
@@ -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;
}
+78 -72
View File
@@ -1,98 +1,104 @@
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'];
login() { if (tabPages.includes(url)) {
wx.getUserProfile({ wx.switchTab({ url });
desc: '用于完善用户资料', } else {
success: (res) => { wx.navigateTo({ url });
this.setData({ userInfo: res.userInfo });
wx.setStorageSync('lunar-user-info', res.userInfo);
wx.showToast({ title: '登录成功', icon: 'success' });
},
fail: () => {
wx.showToast({ title: '登录失败', icon: 'none' });
} }
});
} }
}); });
+41 -56
View File
@@ -1,73 +1,58 @@
<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-base 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>
<block wx:else>
<!-- 偏好设置 --> <view class="flex items-center gap-2 mb-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-base 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 class="btn-primary text-center" bindtap="login">{{logging ? '登录中…' : '微信登录'}}</view>
<view class="text-xs text-muted text-center mt-2">登录后可将生辰档案同步到云端,换设备不丢失</view>
</block>
</view> </view>
<view class="text-center text-muted" wx:else>
<text>暂无收藏</text> <!-- 我的数据 -->
<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> </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">
+16 -27
View File
@@ -10,40 +10,18 @@
} }
.avatar { .avatar {
width: 80rpx; width: 96rpx;
height: 80rpx; height: 96rpx;
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: 40rpx;
}
.bookmark-item {
padding: 16rpx 0;
border-bottom: 1rpx solid #E8D5C4;
}
.bookmark-item:last-child {
border-bottom: none;
} }
.menu-item { .menu-item {
@@ -62,6 +40,17 @@
font-size: 28rpx; font-size: 28rpx;
} }
.menu-right {
display: flex;
align-items: center;
gap: 8rpx;
}
.menu-count {
font-size: 24rpx;
color: #9E8E7E;
}
.menu-arrow { .menu-arrow {
color: #C9B8A6; color: #C9B8A6;
font-size: 36rpx; font-size: 36rpx;
+358 -144
View File
@@ -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,309 @@ 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);
},
// 启动弹幕调度:每行同一时刻只播一条,前一条飘完再出下一条(循环)
startDanmakuScheduler(rows) {
this.stopDanmakuScheduler();
this.danmakuRows = rows || [];
this.danmakuTimers = [];
if (!this.danmakuRows.length) {
this.setData({ currentDanmaku: [] });
return;
}
const GAP = 1.5; // 同一行前一条飘完后,间隔再出下一条(秒)
// 每行独立调度:row 行的第 idx 条播完后,隔 GAP 秒播第 idx+1 条
const playRow = (row, idx) => {
const rowData = this.danmakuRows[row];
if (!rowData) return;
const item = rowData.list[idx % rowData.list.length];
// 更新该行当前弹幕(用行号作为 key 保证 setData 局部刷新)
this.setData({
[`currentDanmaku[${row}]`]: {
key: `${row}-${idx % rowData.list.length}-${Date.now()}`,
id: item.id,
content: item.content,
author: item.author,
type: item.type,
top: rowData.top,
duration: rowData.duration,
},
});
// 前一条飘完 + 间隔后,播下一条
const timer = setTimeout(() => {
playRow(row, idx + 1);
}, (rowData.duration + GAP) * 1000);
this.danmakuTimers[row] = timer;
};
// 初始化 currentDanmaku 数组长度,并启动每行
this.setData({ currentDanmaku: new Array(this.danmakuRows.length).fill(null) });
this.danmakuRows.forEach((_, row) => {
// 各行错开启动,避免 4 行同时从右侧涌出
const startTimer = setTimeout(() => playRow(row, 0), row * 1200);
this.danmakuTimers.push(startTimer);
});
},
// 停止弹幕调度
stopDanmakuScheduler() {
if (this.danmakuTimers) {
this.danmakuTimers.forEach((t) => clearTimeout(t));
this.danmakuTimers = [];
} }
}, },
// 格式化许愿数据 // 构建弹幕:共 4 行(行 0-2 树桩处三行,行 3 顶部置顶轮播下方)
formatWishes(list) { // 按行分组返回,由 JS 定时器控制:同一行一条完毕后再出下一条,不重叠
const colorKeys = Object.keys(TAG_COLORS); buildDanmaku(list) {
const DURATION_BOTTOM = 30; // 树桩处一条飘完全屏的秒数(慢速)
const DURATION_TOP = 24; // 顶部行稍快一点
// 行位置(屏幕百分比):
// - 行3 顶部行紧贴置顶轮播下方(稍有空隙)
// - 行0-2 树桩处三行,第一条68%,三行紧凑(间隔5%)
const ROW_TOPS = [68, 73, 78, 12];
const ROW_DURATIONS = [DURATION_BOTTOM, DURATION_BOTTOM, DURATION_BOTTOM, DURATION_TOP];
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],
duration: ROW_DURATIONS[row],
list: rowList.map((w) => ({
id: w.id,
content: w.content,
author: w.author || '匿名',
type: w.type,
})),
})).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 +424,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 +440,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 +490,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 +503,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 +530,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 +542,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 +549,10 @@ Page({
path: '/pages/wish-tree/wish-tree', path: '/pages/wish-tree/wish-tree',
}; };
}, },
onShareTimeline() {
return {
title: '快来许愿树许下你的愿望吧!'
};
}
}); });
+97 -74
View File
@@ -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">
<block wx:for="{{currentDanmaku}}" wx:key="key" wx:if="{{item}}">
<view
class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
style="top: {{item.top}}%; animation-duration: {{item.duration}}s;"
>{{item.content}} · {{item.author}}</view>
</block>
</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
View File
@@ -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%);
}
}
+147
View File
@@ -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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

+17
View File
@@ -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")
@@ -57,6 +65,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 +84,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)
} }
// 支付相关 // 支付相关
+5 -3
View File
@@ -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/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/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
) )
require ( require (
@@ -36,10 +40,8 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // 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/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/mattn/go-sqlite3 v1.14.22 // 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
gorm.io/gorm v1.31.2 // indirect
) )
+4 -2
View File
@@ -48,6 +48,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=
@@ -83,8 +85,6 @@ 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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
@@ -99,6 +99,8 @@ 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=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+2
View File
@@ -20,6 +20,7 @@ type ServerConfig struct {
} }
type DatabaseConfig struct { type DatabaseConfig struct {
Type string
Host string Host string
Port string Port string
User string User string
@@ -59,6 +60,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"),
+25 -5
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"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,6 +53,7 @@ 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{},
@@ -56,6 +69,13 @@ func autoMigrate() error {
// 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 +85,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
+106
View File
@@ -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 微信授权(仅返回 openidsession_key 属敏感凭证不下发) // WechatAuth 微信授权(仅返回 openidsession_key 属敏感凭证不下发)
func WechatAuth(c *gin.Context) { func WechatAuth(c *gin.Context) {
var req struct { var req struct {
+15
View File
@@ -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"`
}
+2
View File
@@ -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"`
+30
View File
@@ -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{
+8 -2
View File
@@ -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
} }
+5 -4
View File
@@ -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