23 Commits
Author SHA1 Message Date
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
gouki ec55a1d536 fix(ci): 部署链路重构为拉取式,修复工作流问题
Publish Mini Program Dev Version / publish (push) Canceled after 0s
Build and Deploy Server / build (push) Canceled after 0s
- deploy.sh: 目录级原子切换(current.new/current.old)替代 rm -rf 空窗式替换;
  健康检查改为轮询 /api/version HTTP 接口;失败自动回滚上一版本
- 新增 deploy-watch.sh: 服务器本地 systemd timer 轮询 /ci-artifacts 产物指纹,
  发现新版本自动部署——CI 与 AI 不再需要 SSH 到服务器执行任何命令
- server-deploy.yml: 部署包补入 web/ 后台静态资源;移除永不生效的前端构建死代码;
  通知文案如实改为'构建成功'(部署由服务器侧 watcher 完成)
- miniapp-preview.yml: 移除无意义的 --runInBand 测试参数
- 新增 PULL_DEPLOY.md 一次性安装文档(管理员手工执行)
2026-08-09 00:10:45 +00:00
gouki f90b7c7764 fix(mini): BASE_URL 按运行环境自动切换
- develop 走本地开发服务,trial/release 走 https://lunar.neatcn.com(原为硬编码 localhost:8080,线上无法连通)
- 401 时清除失效 token 交由页面引导重新登录,不再跳转设置页
- 补交 version-badge 组件源码
2026-08-09 00:10:05 +00:00
gouki 63f92501a1 fix(server): 管理后台源码入库,修正 .gitignore 误忽略
- server/web 之前被 web/ 忽略规则误伤,从未提交到仓库,导致 CI 部署包缺失后台静态资源
- 锚定根目录 /web/、/wishing-tree-pages/ 规则,仅忽略构建产物 node_modules/dist
2026-08-09 00:10:05 +00:00
gouki b8c439deac fix(server): 安全加固与版本链路修复
- 微信登录改为真实 jscode2session,session_key 不再下发客户端
- 支付回调增加 HMAC 验签(X-Pay-Sign)与幂等处理,未配置密钥时拒绝回调
- 订单金额一律以服务端商品表定价,禁止客户端传入金额
- 付费许愿改为支付成功后创建,不再先许愿后付款
- 管理后台增加登录认证(ADMIN_PASSWORD + role=admin JWT + HttpOnly Cookie)
- 订单详情/取消增加本人归属校验,修复越权访问
- 版本信息改为 ldflags 注入单一链路,GoVersion 用 runtime.Version()
- 恢复 gin 默认访问日志(原 Logger 中间件输出为空)
- 加载 HTML 模板修复后台页面 500;godotenv 加载 .env.local
- CORS 支持 CORS_ORIGINS 白名单配置
2026-08-09 00:09:24 +00:00
51 changed files with 3701 additions and 965 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 实现后端服务的全自动部署。
## 架构说明 ## 架构说明
+125
View File
@@ -0,0 +1,125 @@
# doc79 同机拉取式部署配置(1Panel 原生方式,一次性)
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由服务器本地定时任务监听 CI 产物目录自动完成。
> 本文档所有操作均通过 **1Panel 网页界面**或管理员本人的终端完成,不依赖 AI 远程执行。
## 架构
```
push main → Gitea Actionshk runner 构建)→ 部署包发布为 Gitea Release 附件
1Panel 计划任务(每分钟)运行 deploy-watch.sh → 发现新 release → HTTPS 拉取 deploy.tar.gz
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
# 可选:若仓库私有或需要令牌拉取,在 .env 中配置 GITEA_DEPLOY_TOKEN
[ -f /opt/lunar/production/.env ] && set -a && . /opt/lunar/production/.env && set +a
bash /opt/lunar/scripts/deploy-watch.sh
```
保存后可先点「执行」手动跑一次验证(日志在任务详情里查看)。
### 7. 微信公众平台
开发管理 → 服务器域名:将 `https://lunar.neatcn.com` 加入 **request 合法域名**
## 验证
```bash
curl -s https://lunar.neatcn.com/api/version # 应返回含 commitSha 的版本 JSON
```
- CI 侧:push 后 Gitea Actions 日志显示"后端构建成功"Telegram/Discord 收到通知;
- 服务器侧:1Panel 计划任务日志出现"部署成功";容器日志可见 `lunar-server 1.0.x` 启动行。
## 回滚
- 自动: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 能力内,日志、重启、证书都在一个面板里可见可控,
避免绕过平台管理造成"幽灵配置"。
+282
View File
@@ -0,0 +1,282 @@
# 版本号管理指南
本文档说明如何在小程序和后端服务中管理和显示版本号。
## 版本号机制
### 小程序版本号
- **格式**: `x.y.z`(微信要求每段 0-999
- **生成规则**:
- `x` = package.json 中的 major 版本
- `y` = CI 构建编号 / 100
- `z` = CI 构建编号 % 100
- **示例**: 构建编号 42 → 版本 `1.0.42`,构建编号 123 → 版本 `1.1.23`
### 后端版本号
- **格式**: `1.0.{BUILD_NUMBER}`
- **生成规则**: 使用 CI 构建编号作为 patch 版本
- **示例**: 构建编号 42 → 版本 `1.0.42`
## 版本号注入流程
### 小程序(CI 构建时)
1. CI 读取 `MINIAPP_BUILD_NUMBER`Gitea Actions 运行编号)
2. 调用 `resolve-version.cjs` 生成版本号
3. 将版本号写入 `mini/utils/version.js`
4. 上传小程序时使用该版本号
**生成的 version.js 示例**:
```javascript
module.exports = {
version: "1.0.42",
buildNumber: "42",
buildTime: "2026-08-08T12:34:56Z",
commitSha: "abc1234",
};
```
### 后端(CI 构建时)
1. CI 读取 `GITEA_RUN_NUMBER`Gitea Actions 运行编号)
2. 生成版本号 `1.0.{RUN_NUMBER}`
3. 使用 `ldflags` 注入到 Go 二进制文件
4. 同时保存到 `bin/version.env` 文件
**生成的 version.env 示例**:
```
APP_VERSION=1.0.42
APP_BUILD_TIME=2026-08-08T12:34:56Z
APP_COMMIT_SHA=abc1234
```
## 版本号显示
### 小程序端
#### 1. 个人中心页(settings
自动显示版本号和构建信息:
```
老黄历小程序 v1.0.42
构建 #42 (abc1234)
提供农历、黄历、八字、许愿等功能
```
#### 2. 使用版本号组件
在其他页面中使用 `version-badge` 组件:
**wxml**:
```xml
<!-- 简单显示 -->
<version-badge />
<!-- 显示详细信息 -->
<version-badge showDetail="{{true}}" />
<!-- 显示构建时间 -->
<version-badge showDetail="{{true}}" showTime="{{true}}" />
```
**json**:
```json
{
"usingComponents": {
"version-badge": "/components/version-badge/version-badge"
}
}
```
**显示效果**:
- 简单: `v1.0.42`
- 详细: `v1.0.42 #42 (abc1234)`
- 完整: `v1.0.42 #42 (abc1234) 2026/08/08 12:34`
### 后端 API
#### 获取版本信息
**接口**: `GET /api/version`
**响应**:
```json
{
"version": "1.0.42",
"buildTime": "2026-08-08T12:34:56Z",
"commitSha": "abc1234",
"goVersion": "go1.22"
}
```
**使用示例**:
```bash
curl http://localhost:8080/api/version
```
## 部署通知中的版本号
### 小程序部署通知
```
✅ 小程序开发版上传成功
状态: 成功
仓库: gouki/lunar
分支: main
提交: abc1234
作者: gouki
工作流: Publish Mini Program Dev Version #42
版本: 1.0.42 上传结果已保存到: /opt/lunar/ci-artifacts/miniapp-upload-latest.json
```
### 后端部署通知
```
✅ 后端服务部署成功
状态: 成功
仓库: gouki/lunar
分支: main
提交: abc1234
作者: gouki
工作流: Build and Deploy Server #42
版本: v1.0.42
部署包: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz
```
## 版本号一致性检查
### 本地开发环境
**小程序**:
```bash
# 查看 package.json 中的版本
cat mini/package.json | grep version
# 查看 version.js 中的版本
cat mini/utils/version.js
```
**后端**:
```bash
# 本地运行时版本为 "dev"
curl http://localhost:8080/api/version
```
### 生产环境
**小程序**:
1. 打开小程序
2. 进入"我的"页面
3. 查看"关于"部分的版本号
**后端**:
```bash
# 访问生产环境的版本接口
curl https://your-domain.com/api/version
```
### CI 构建产物
**小程序**:
```bash
# SSH 到服务器
ssh doc79
# 查看上传结果
cat /opt/lunar/ci-artifacts/miniapp-upload-latest.json | jq .version
```
**后端**:
```bash
# 查看版本信息文件
cat /opt/lunar/production/current/bin/version.env
# 或者访问 API
curl http://localhost:8080/api/version
```
## 版本号追踪
### 通过 Git Commit SHA
每个版本都包含 Git commit SHA(前 7 位),可以追溯到具体的代码提交:
```bash
# 查看某个版本的完整 commit 信息
git show abc1234
# 查看某个版本的变更内容
git log --oneline abc1234^..abc1234
```
### 通过构建编号
构建编号对应 Gitea Actions 的运行编号:
1. 访问 Gitea 网页界面
2. 进入仓库 → Actions
3. 查找对应编号的运行记录
4. 查看完整的构建日志和产物
## 故障排查
### 版本号不正确
**问题**: 小程序显示的版本号与预期不符
**排查步骤**:
1. 检查 `mini/utils/version.js` 是否被正确生成
2. 检查 CI 日志中的版本号解析过程
3. 确认 `MINIAPP_BUILD_NUMBER` 环境变量是否正确传递
### 版本号未更新
**问题**: 部署后版本号没有变化
**可能原因**:
1. CI 构建缓存导致 `version.js` 未被重新生成
2. 小程序缓存导致旧版本仍在使用
**解决方法**:
1. 清除 CI 构建缓存
2. 在小程序中清除缓存并重新编译
3. 确认 CI 日志中版本号已更新
### 后端版本号显示为 "dev"
**问题**: 生产环境的 `/api/version` 返回 `"version": "dev"`
**原因**: 环境变量未正确注入
**解决方法**:
1. 检查 CI 构建日志,确认 `ldflags` 注入成功
2. 检查部署脚本,确认 `version.env` 文件被正确加载
3. 检查容器环境变量配置
## 最佳实践
1. **版本号语义化**: 遵循语义化版本规范(SemVer)
2. **版本号可见性**: 在用户界面和 API 中都显示版本号
3. **版本号追踪**: 通过 commit SHA 和构建编号追踪版本
4. **版本号一致性**: 确保本地、CI、生产环境的版本号一致
5. **版本号文档**: 在 CHANGELOG 中记录每个版本的变更
## 相关文件
- 小程序版本配置: `mini/utils/version.js`
- 小程序版本组件: `mini/components/version-badge/`
- 后端版本接口: `server/internal/handler/version.go`
- 小程序 CI 配置: `.gitea/workflows/miniapp-preview.yml`
- 后端 CI 配置: `.gitea/workflows/server-deploy.yml`
- 版本解析脚本: `mini/ci/resolve-version.cjs`
## 更新日志
- 2026-08-08: 初始版本,支持小程序和后端的版本号注入、显示和追踪
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# 拉取式部署 watcher - 从 Gitea Release 拉取最新部署包并自动部署
# 设计原则:
# - CI 在任意 runnerhk 等)构建,产物以 Release 附件发布到 Gitea
# - 本脚本由服务器本地定时任务(1Panel 计划任务)每分钟调用,通过 HTTPS 拉取,
# 任何 AI/CI 均不通过 SSH 操作服务器
# 安装方式见 .gitea/docs/PULL_DEPLOY.md1Panel 原生方式,一次性配置)
#
# 可选环境变量(建议写入 /opt/lunar/production/.env 并在计划任务里 source):
# GITEA_DEPLOY_TOKEN 只读令牌(仓库公开时可省略,匿名即可下载)
set -u
GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}"
REPO="${REPO:-gouki/lunar-mini}"
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-release}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
AUTH_ARGS=()
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
fi
RELEASE_ID=$(echo "$PARSED" | sed -n 1p)
TAG=$(echo "$PARSED" | sed -n 2p)
ASSET_ID=$(echo "$PARSED" | sed -n 3p)
DOWNLOAD_URL=$(echo "$PARSED" | sed -n 4p)
# 幂等:已部署过该 release 则跳过
LAST="$(cat "$STATE_FILE" 2>/dev/null || true)"
if [ "$LAST" = "$RELEASE_ID" ]; then
exit 0
fi
log "发现新版本 $TAG (release #$RELEASE_ID),开始拉取部署"
# 下载部署包(公开仓库可匿名;-L 跟随重定向)
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")"
printf '%s' "$RELEASE_ID" > "$STATE_FILE"
log "部署成功: $TAG"
else
log "部署失败($TAG),保留旧版本,等待下次轮询重试或人工介入"
exit 1
fi
+50 -23
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# 自动部署脚本 - 部署到 1Panel Docker 容器 # 自动部署脚本 - 部署到 1Panel Docker 容器
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
# 使用方法: ./deploy.sh <deploy_package_path> # 使用方法: ./deploy.sh <deploy_package_path>
# deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz # deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz
@@ -9,6 +10,7 @@ DEPLOY_PACKAGE="$1"
CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}" CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}" DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}"
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}" BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}"
HEALTH_URL="${HEALTH_URL:-http://localhost:8080/api/version}"
# 颜色输出 # 颜色输出
RED='\033[0;31m' RED='\033[0;31m'
@@ -28,6 +30,18 @@ log_error() {
echo -e "${RED}[ERROR]${NC} $1" echo -e "${RED}[ERROR]${NC} $1"
} }
# 回滚函数:恢复 current.old 并重启容器
rollback() {
if [ -d "$DEPLOY_DIR/current.old" ]; then
rm -rf "$DEPLOY_DIR/current"
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
docker restart "$CONTAINER_NAME" || log_error "回滚后容器重启失败,需人工介入"
log_warn "已回滚到上一版本"
else
log_error "无可回滚版本,需人工介入"
fi
}
# 检查部署包是否存在 # 检查部署包是否存在
if [ ! -f "$DEPLOY_PACKAGE" ]; then if [ ! -f "$DEPLOY_PACKAGE" ]; then
log_error "部署包不存在: $DEPLOY_PACKAGE" log_error "部署包不存在: $DEPLOY_PACKAGE"
@@ -52,7 +66,7 @@ if [ -d "$DEPLOY_DIR/current" ]; then
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署" tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
fi fi
# 解压新版本 # 解压新版本到暂存目录(不碰 current,避免部署空窗)
log_info "解压部署包..." log_info "解压部署包..."
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR" tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
@@ -64,15 +78,23 @@ if [ ! -d "$TEMP_DIR/deploy" ]; then
exit 1 exit 1
fi fi
# 部署新版本 # 校验二进制存在
log_info "部署新版本..." if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
rm -rf "$DEPLOY_DIR/current" log_error "部署包缺少 bin/server 二进制"
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current" rm -rf "$TEMP_DIR"
rm -rf "$TEMP_DIR" exit 1
fi
chmod +x "$TEMP_DIR/deploy/bin/server"
# 设置权限 # 部署新版本(目录级交换,接近原子)
log_info "设置文件权限..." log_info "部署新版本..."
chmod +x "$DEPLOY_DIR/current/bin/server" || log_warn "设置可执行权限失败" mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
rm -rf "$DEPLOY_DIR/current.old"
if [ -d "$DEPLOY_DIR/current" ]; then
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
fi
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
rm -rf "$TEMP_DIR"
# 检查 Docker 容器是否存在 # 检查 Docker 容器是否存在
if ! docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then if ! docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
@@ -95,25 +117,30 @@ log_info "重启 Docker 容器: $CONTAINER_NAME"
if docker restart "$CONTAINER_NAME"; then if docker restart "$CONTAINER_NAME"; then
log_info "✅ 容器重启成功" log_info "✅ 容器重启成功"
else else
log_error "❌ 容器重启失败" log_error "❌ 容器重启失败,回滚到上一版本"
rollback
exit 1 exit 1
fi fi
# 等待容器启动 # 健康检查:轮询 HTTP 接口确认服务真正可用
log_info "等待容器启动..." log_info "执行健康检查: $HEALTH_URL"
sleep 3 HEALTHY=0
for i in $(seq 1 10); do
sleep 2
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=1
break
fi
done
# 检查容器状态 if [ "$HEALTHY" = "1" ]; then
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then log_info "✅ 健康检查通过"
log_info "✅ 容器运行正常" curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
rm -rf "$DEPLOY_DIR/current.old"
# 显示容器日志(最后 20 行)
log_info "容器日志(最后 20 行):"
docker logs --tail 20 "$CONTAINER_NAME"
else else
log_error "❌ 容器未运行" log_error "❌ 健康检查失败,回滚到上一版本"
log_error "容器日志:" docker logs --tail 50 "$CONTAINER_NAME" || true
docker logs --tail 50 "$CONTAINER_NAME" rollback
exit 1 exit 1
fi fi
+69 -37
View File
@@ -11,26 +11,67 @@ 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 遇已存在目录会合并而非替换,
# 残留的 glibc 版 node 会污染 musl 环境导致 symbol not found
rm -rf "$TOOLDIR"
mkdir -p "$TOOLDIR"
# 探测 libcAlpine(musl) 用 unofficial musl 构建,其余用官方 glibc 构建(tar.gzbusybox tar 不支持 xz
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"
wget -q --tries=2 --timeout=120 -O "$TOOLDIR/node.tgz" \
"https://unofficial-builds.nodejs.org/download/release/v22.12.0/$NODE_DIST.tar.gz"
else
NODE_DIST="node-v22.12.0-linux-x64"
wget -q --tries=2 --timeout=120 -O "$TOOLDIR/node.tgz" \
"https://nodejs.org/dist/v22.12.0/$NODE_DIST.tar.gz" \
|| wget -q --tries=2 --timeout=120 -O "$TOOLDIR/node.tgz" \
"https://mirrors.aliyun.com/nodejs-release/v22.12.0/$NODE_DIST.tar.gz"
fi
tar -C "$TOOLDIR" -xzf "$TOOLDIR/node.tgz"
mv "$TOOLDIR/$NODE_DIST" "$TOOLDIR/node"
rm -f "$TOOLDIR/node.tgz"
fi
"$TOOLDIR/node/bin/node" --version
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 -- --runInBand 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"
@@ -39,7 +80,7 @@ jobs:
mkdir -p ci-artifacts mkdir -p ci-artifacts
printf '%s\n' "$VERSION" > ci-artifacts/resolved-version.txt printf '%s\n' "$VERSION" > ci-artifacts/resolved-version.txt
echo "Resolved Mini Program version for build+upload: $VERSION" echo "Resolved Mini Program version for build+upload: $VERSION"
# 注入版本号到 utils/version.js # 注入版本号到 utils/version.js
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}"
@@ -48,7 +89,7 @@ jobs:
* 版本号配置文件 * 版本号配置文件
* 此文件由 CI 构建时自动生成,请勿手动修改 * 此文件由 CI 构建时自动生成,请勿手动修改
*/ */
module.exports = { module.exports = {
version: "$VERSION", version: "$VERSION",
buildNumber: "$MINIAPP_BUILD_NUMBER", buildNumber: "$MINIAPP_BUILD_NUMBER",
@@ -58,6 +99,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 +107,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 +115,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,31 +132,21 @@ 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()
env: env:
@@ -123,8 +155,8 @@ 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()
env: env:
@@ -134,8 +166,8 @@ 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()
run: rm -f "$RUNNER_TEMP/wechat-ci.key" run: rm -f "$RUNNER_TEMP/wechat-ci.key"
+146 -60
View File
@@ -1,4 +1,4 @@
name: Build and Deploy Server name: Build and Publish Server
on: on:
push: push:
@@ -10,72 +10,96 @@ 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 }}
- name: Verify Go runtime REF_NAME: ${{ gitea.ref_name }}
run: go version run: |
git init -q .
git remote add origin "$REPO_URL"
git fetch -q --depth 20 origin "$REF_NAME"
git checkout -q FETCH_HEAD
git log --oneline -3
- name: Ensure curl available
run: |
# 精简 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
env: env:
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 注入版本信息;目标平台为 doc79linux/amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
# 使用 ldflags 注入版本信息
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: Build frontend (if exists)
working-directory: server
run: |
if [ -d "web" ]; then
echo "Building frontend..."
cd web
if [ -f "package.json" ]; then
npm ci
npm run build
fi
else
echo "No frontend directory found, skipping frontend build"
fi
- name: Create deployment package - name: Create deployment package
working-directory: server working-directory: server
run: | run: |
@@ -83,20 +107,82 @@ jobs:
cp -r bin/ deploy/ cp -r bin/ deploy/
cp -r migrations/ deploy/ cp -r migrations/ deploy/
cp -r scripts/ deploy/ cp -r scripts/ deploy/
if [ -d "web/dist" ]; then # 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
cp -r web/dist/ deploy/public/ if [ -d "web" ]; then
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()
env: env:
@@ -105,11 +191,11 @@ 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" "版本: v${VERSION}\n产物: releases/tag/server-v${VERSION}\ndoc79 将自动拉取部署" || echo "通知发送失败,不影响主流程"
- name: Send failure notification - name: Send failure notification
if: failure() if: failure()
env: env:
@@ -118,9 +204,9 @@ jobs:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
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()
run: | run: |
+6 -5
View File
@@ -55,14 +55,15 @@ server/vendor/
*.test *.test
*.out *.out
# 前端构建(如果 server 包含前端 # 前端构建产物与依赖(注意:server/web 是管理后台源码,必须入库
server/public/build/ server/public/build/
server/public/hot server/public/hot
server/web/ server/web/node_modules/
server/web/dist/
# web 版万年历(独立项目,不属于本仓库) # web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web
web/ /web/
wishing-tree-pages/ /wishing-tree-pages/
# 数据库 # 数据库
*.db *.db
+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.
@@ -0,0 +1,46 @@
/**
* 版本号显示组件
* 使用方法:
* <version-badge />
*/
const versionInfo = require('../../utils/version.js');
Component({
properties: {
// 是否显示详细信息(构建编号、commit SHA)
showDetail: {
type: Boolean,
value: false
},
// 是否显示构建时间
showTime: {
type: Boolean,
value: false
}
},
data: {
version: versionInfo.version,
buildNumber: versionInfo.buildNumber,
buildTime: versionInfo.buildTime,
commitSha: versionInfo.commitSha
},
lifetimes: {
attached() {
// 格式化构建时间
if (this.data.buildTime && this.data.buildTime !== 'unknown') {
const date = new Date(this.data.buildTime);
const formatted = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
this.setData({ formattedTime: formatted });
}
}
}
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}
@@ -0,0 +1,6 @@
<view class="version-badge">
<text class="version-text">v{{version}}</text>
<text class="version-detail" wx:if="{{showDetail && buildNumber !== '0'}}">#{{buildNumber}}</text>
<text class="version-detail" wx:if="{{showDetail && commitSha !== 'unknown'}}">({{commitSha}})</text>
<text class="version-time" wx:if="{{showTime && formattedTime}}">{{formattedTime}}</text>
</view>
@@ -0,0 +1,22 @@
.version-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #9E8E7E;
}
.version-text {
font-weight: 500;
}
.version-detail {
font-size: 11px;
opacity: 0.8;
}
.version-time {
font-size: 11px;
opacity: 0.6;
margin-left: 4px;
}
+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": {
+130 -55
View File
@@ -1,75 +1,150 @@
const { birthInfoToBazi, analyzeElementBalance } = require('../../utils/core/index.js'); const { birthInfoToBazi, analyzeElementBalance } = require('../../utils/core/index.js');
const birthProfile = require('../../utils/birth-profile.js');
// 空态演示用的示例生辰(虚构,仅作排盘样式展示)
const DEMO_BIRTH = { year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male', ziSect: 'lateZiNextDay' };
// 五行 → 样式类名
const ELEMENT_CLASS = { '木': 'el-wood', '火': 'el-fire', '土': 'el-earth', '金': 'el-metal', '水': 'el-water' };
// 八字知识词条:逐字段详细说明
const GLOSSARY = [
{ term: '四柱八字', desc: '用天干地支记录出生年、月、日、时的方法,年、月、日、时各一对干支,合称"四柱",共八个字,故称"八字"。它源于古代干支纪时体系,是传统历法文化的一部分。' },
{ term: '天干地支', desc: '天干共十个(甲乙丙丁戊己庚辛壬癸),地支共十二个(子丑寅卯辰巳午未申酉戌亥),是古人用于纪年、纪月、纪日、纪时的符号系统,也是传统历法的基础。' },
{ term: '年柱', desc: '出生年份的干支。传统上以立春为界划分年份(立春前出生按上一年)。年柱在民俗说法中对应祖辈与少年时期。' },
{ term: '月柱', desc: '出生月份的干支。月份以节气划分(如立春至惊蛰为正月),并非公历或农历的自然月。民俗说法中对应父母与青年时期。' },
{ term: '日柱', desc: '出生当日的干支,按六十甲子循环纪日。日柱的天干代表本人(日主),是整个命盘分析的核心。' },
{ term: '时柱', desc: '出生时辰的干支。古人将一昼夜分为十二个时辰,每个时辰合今两小时(如子时为 23:00-01:00)。' },
{ term: '日主', desc: '日柱的天干,代表命主本人。日主的五行属性(木火土金水)是分析命盘五行强弱与十神关系的出发点。' },
{ term: '十神', desc: '以日主为参照,按五行生克关系对其他干支的十种称谓:比肩、劫财、食神、伤官、偏财、正财、七杀、正官、偏印、正印。它是古代的一种关系分类符号,用于描述干支之间的生克比和。' },
{ term: '五行', desc: '木、火、土、金、水五种基本元素,源于古代哲学,认为万物由五行构成并相生相克(木生火、火生土、土生金、金生水、水生木;木克土、土克水、水克火、火克金、金克木)。五行分析统计八字中各元素的数量与强弱。' },
{ term: '藏干', desc: '又称"人元",指每个地支内部按传统规则所含的一个至三个天干。例如寅中藏甲、丙、戊。排盘时作为辅助信息列出。' },
{ term: '纳音', desc: '将六十甲子两两配对归入五行所得的三十种称谓,如"甲子乙丑海中金"、"丙寅丁卯炉中火"。年命纳音(年柱纳音)在民俗中常被用来称呼人的"命",如"金命"、"火命"。' },
{ term: '空亡', desc: '六十甲子分为六旬,每旬十个干支配十二地支,余下两个地支无天干相配,称为该旬"空亡"。这是干支纪法本身的结构性概念,此处仅作历法知识展示。' },
{ term: '大运', desc: '传统上以月柱为基础、按十年一换的节奏推演出的干支序列,用来描述人生不同阶段的节律。属于传统民俗推演方法,供文化了解。' },
{ term: '流年', desc: '即每一年的干支(如 2026 年为丙午年)。传统做法将流年与本命干支对照,观察逐年的变化关系。' },
{ term: '子时流派', desc: '子时(23:00-01:00)横跨两天,晚子时(23:00-24:00)出生者的日柱按当天还是次日起算,历代流派说法不一。可在"我的-偏好设置"中选择。' }
];
/** 根据出生信息构建排盘展示数据 */
function buildChart({ year, month, day, hour, minute, gender, ziSect }) {
const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect });
const elementAnalysis = analyzeElementBalance(bazi.eightChar);
const pillars = [
{ label: '年柱', ...bazi.eightChar.yearPillar },
{ label: '月柱', ...bazi.eightChar.monthPillar },
{ label: '日柱', ...bazi.eightChar.dayPillar },
{ 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 elements = [
{ 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), cls: 'el-fire' },
{ 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), cls: 'el-metal' },
{ name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100), cls: 'el-water' }
];
return { bazi, pillars, elements, elementAnalysis };
}
Page({ Page({
data: { data: {
gender: 'male', isDemo: true,
birthDate: '1990-01-01', profiles: [],
birthTime: '12:00', activeProfileId: '',
ziSect: 'lateZiNextDay', profile: null,
bazi: null, bazi: null,
pillars: [], pillars: [],
elements: [], elements: [],
elementAnalysis: {} elementAnalysis: {},
glossary: GLOSSARY,
showGlossary: false
}, },
onLoad() { onShow() {
// 尝试从本地存储恢复 this.refresh();
const saved = wx.getStorageSync('lunar-bazi-input'); },
if (saved) {
this.setData(saved); 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();
} }
}, },
setGender(e) { /** 空态:展示示例排盘 */
this.setData({ gender: e.currentTarget.dataset.g }); showDemo() {
const chart = buildChart(DEMO_BIRTH);
this.setData({
isDemo: true,
profiles: [],
activeProfileId: '',
profile: null,
showGlossary: true,
...chart
});
}, },
setZiSect(e) { /** 已绑定:展示所选档案的排盘 */
this.setData({ ziSect: e.currentTarget.dataset.s }); showProfile(profiles, profile) {
}, if (!profile) return;
const [year, month, day] = profile.birthday.split('-').map(Number);
onDateChange(e) { const [hour, minute] = profile.birthTime.split(':').map(Number);
this.setData({ birthDate: e.detail.value }); const settings = wx.getStorageSync('lunar-settings') || {};
}, const chart = buildChart({
year, month, day, hour, minute,
onTimeChange(e) { gender: profile.gender,
this.setData({ birthTime: e.detail.value }); ziSect: settings.ziHourSect || 'lateZiNextDay'
}, });
calculate() {
const { gender, birthDate, birthTime, ziSect } = this.data;
const [year, month, day] = birthDate.split('-').map(Number);
const [hour, minute] = birthTime.split(':').map(Number);
const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect });
const elementAnalysis = analyzeElementBalance(bazi.eightChar);
const pillars = [
{ label: '年柱', ...bazi.eightChar.yearPillar },
{ label: '月柱', ...bazi.eightChar.monthPillar },
{ label: '日柱', ...bazi.eightChar.dayPillar },
{ label: '时柱', ...bazi.eightChar.hourPillar }
];
const total = elementAnalysis.total || 1;
const elements = [
{ name: '木', count: elementAnalysis.wood, percent: Math.round(elementAnalysis.wood / total * 100) },
{ name: '火', count: elementAnalysis.fire, percent: Math.round(elementAnalysis.fire / total * 100) },
{ name: '土', count: elementAnalysis.earth, percent: Math.round(elementAnalysis.earth / total * 100) },
{ name: '金', count: elementAnalysis.metal, percent: Math.round(elementAnalysis.metal / total * 100) },
{ name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100) }
];
this.setData({ bazi, pillars, elements, elementAnalysis });
// 保存输入
wx.setStorageSync('lunar-bazi-input', { gender, birthDate, birthTime, ziSect });
// 保存八字供运势页使用 // 保存八字供运势页使用
wx.setStorageSync('lunar-user-bazi', bazi); wx.setStorageSync('lunar-user-bazi', chart.bazi);
this.setData({
isDemo: false,
profiles,
activeProfileId: profile.id,
profile,
showGlossary: false,
...chart
});
}, },
reset() { selectProfile(e) {
this.setData({ bazi: null }); 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.switchTab({ url: '/pages/settings/settings' });
}, },
goFortune() { goFortune() {
+150 -97
View File
@@ -1,104 +1,157 @@
<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 class="form-item">
<text class="form-label">性别</text>
<view class="flex gap-2">
<view class="btn-outline {{gender === 'male' ? 'active' : ''}}" bindtap="setGender" data-g="male">男</view>
<view class="btn-outline {{gender === 'female' ? 'active' : ''}}" bindtap="setGender" data-g="female">女</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> </view>
<!-- 八字结果 --> <!-- 空态:示例提示 + 绑定入口 -->
<block wx:if="{{bazi}}"> <block wx:if="{{isDemo}}">
<!-- 四柱 --> <view class="card empty-card">
<view class="card"> <view class="empty-title">你还没有绑定生辰信息</view>
<view class="section-title">四柱八字</view> <view class="empty-desc">下方是一份示例排盘,帮助你了解八字页面的内容。在个人中心可免费绑定 3 个出生年月(本人、家人),绑定后即可查看专属排盘。</view>
<view class="grid grid-cols-4 gap-2"> <view class="btn-primary text-center mt-2" bindtap="goSettings">去绑定我的生辰</view>
<view class="pillar" wx:for="{{pillars}}" wx:key="label">
<text class="pillar-label">{{item.label}}</text>
<text class="pillar-ganzhi">{{item.ganzhi}}</text>
<text class="pillar-ten">{{item.tenStar || '日主'}}</text>
</view>
</view>
</view> </view>
<view class="demo-banner">
<!-- 五行分析 --> <text class="demo-tag">示例</text>
<view class="card"> <text class="demo-text">以下排盘基于虚构生辰(1990-08-08 10:30 男),仅演示页面效果</text>
<view class="section-title">五行分析</view>
<view class="element-bar">
<view class="element-item" wx:for="{{elements}}" wx:key="name">
<text class="element-name">{{item.name}}</text>
<view class="element-track">
<view class="element-fill" style="width: {{item.percent}}%"></view>
</view>
<text class="element-count">{{item.count}}</text>
</view>
</view>
<view class="mt-2 text-sm text-muted">
日主:{{bazi.eightChar.dayMaster}} | dominant: {{elementAnalysis.dominant}} | weakest: {{elementAnalysis.weakest}}
</view>
</view>
<!-- 大运 -->
<view class="card">
<view class="section-title">大运</view>
<scroll-view scroll-x class="scroll-x">
<view class="flex gap-2">
<view class="fortune-item" wx:for="{{bazi.decadeFortunes}}" wx:key="index">
<text class="text-sm text-muted">{{item.startAge}}-{{item.endAge}}岁</text>
<text class="text-base font-medium">{{item.ganzhi}}</text>
<text class="text-xs text-muted">{{item.startYear}}-{{item.endYear}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 流年 -->
<view class="card">
<view class="section-title">流年</view>
<scroll-view scroll-x class="scroll-x">
<view class="flex gap-2">
<view class="fortune-item" wx:for="{{bazi.annualFortunes}}" wx:key="year">
<text class="text-sm text-muted">{{item.age}}岁</text>
<text class="text-base font-medium">{{item.ganzhi}}</text>
<text class="text-xs text-muted">{{item.year}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 操作 -->
<view class="flex gap-2 mt-2">
<view class="btn-outline flex-1 text-center" bindtap="reset">重新排盘</view>
<view class="btn-primary flex-1 text-center" bindtap="goFortune">看运势</view>
</view> </view>
</block> </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 class="card">
<view class="section-title">四柱八字</view>
<view class="grid grid-cols-4 gap-2">
<view class="pillar" wx:for="{{pillars}}" wx:key="label">
<text class="pillar-label">{{item.label}}</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 class="card">
<view class="section-title">五行分析</view>
<view class="element-bar">
<view class="element-item" wx:for="{{elements}}" wx:key="name">
<text class="element-name">{{item.name}}</text>
<view class="element-track">
<view class="element-fill {{item.cls}}" style="width: {{item.percent}}%"></view>
</view>
<text class="element-count">{{item.count}}</text>
</view>
</view>
<view class="mt-2 text-xs text-muted">按天干、地支计 1 分,藏干计 0.5 分;得分越高代表该五行在命盘中越突出。</view>
</view>
<!-- 大运 -->
<view class="card">
<view class="section-title">大运(十年一步)</view>
<scroll-view scroll-x class="scroll-x">
<view class="flex gap-2">
<view class="fortune-item" wx:for="{{bazi.decadeFortunes}}" wx:key="index">
<text class="text-sm text-muted">{{item.startAge}}-{{item.endAge}}岁</text>
<text class="text-base font-medium">{{item.ganzhi}}</text>
<text class="text-xs text-muted">{{item.startYear}}-{{item.endYear}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 流年 -->
<view class="card">
<view class="section-title">流年</view>
<scroll-view scroll-x class="scroll-x">
<view class="flex gap-2">
<view class="fortune-item" wx:for="{{bazi.annualFortunes}}" wx:key="year">
<text class="text-sm text-muted">{{item.age}}岁</text>
<text class="text-base font-medium">{{item.ganzhi}}</text>
<text class="text-xs text-muted">{{item.year}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 字段说明 -->
<view class="card">
<view class="glossary-header" bindtap="toggleGlossary">
<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>
</block>
</view>
<!-- 底部操作 -->
<view class="flex gap-2 mt-2 bottom-actions">
<block wx:if="{{isDemo}}">
<view class="btn-primary flex-1 text-center" bindtap="goSettings">绑定生辰,查看我的排盘</view>
</block>
<block wx:else>
<view class="btn-outline flex-1 text-center" bindtap="goSettings">管理生辰</view>
<view class="btn-primary flex-1 text-center" bindtap="goFortune">看今日运势</view>
</block>
</view>
<!-- 底部免责声明 -->
<view class="disclaimer">
八字排盘为传统历法文化展示,内容仅供学习参考,请理性看待,切勿迷信。
</view>
</view> </view>
+200 -23
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;
}
+8 -7
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-toggle" bindtap="toggleView"> <view class="nav-left">
<text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text> <view class="nav-toggle" bindtap="toggleView">
</view> <text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text>
<text class="nav-title font-chinese">老黄历</text> </view>
<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 {
+163 -15
View File
@@ -1,14 +1,28 @@
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,
loggedIn: false,
logging: false,
weekStartDay: 0, weekStartDay: 0,
ziHourSect: 'lateZiNextDay', ziHourSect: 'lateZiNextDay',
solarTime: true, solarTime: true,
showBuddhist: false, showBuddhist: false,
highlightLunar: true, highlightLunar: true,
bookmarks: [], bookmarks: [],
// 生辰档案
profiles: [],
activeProfileId: '',
maxFree: birthProfile.MAX_PROFILES,
canAdd: true,
showAddForm: false,
formName: '',
formGender: 'male',
formDate: '1990-01-01',
formTime: '12:00',
version: versionInfo.version, version: versionInfo.version,
buildNumber: versionInfo.buildNumber, buildNumber: versionInfo.buildNumber,
buildTime: versionInfo.buildTime, buildTime: versionInfo.buildTime,
@@ -22,6 +36,7 @@ Page({
onShow() { onShow() {
this.loadBookmarks(); this.loadBookmarks();
this.loadProfiles();
}, },
loadSettings() { loadSettings() {
@@ -45,6 +60,146 @@ Page({
this.setData({ bookmarks }); this.setData({ bookmarks });
}, },
// ==================== 生辰档案 ====================
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();
}
});
},
// ==================== 登录 ====================
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' });
// 登录后同步服务端生辰档案
this.loadProfiles();
} 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' });
}
});
},
logout() {
wx.showModal({
title: '退出登录',
content: '退出后生辰档案仅保存在本机,重新登录可恢复云端档案',
success: (res) => {
if (!res.confirm) return;
wx.removeStorageSync('token');
wx.removeStorageSync('lunar-user-info');
this.setData({ userInfo: null, loggedIn: false });
wx.showToast({ title: '已退出', icon: 'success' });
}
});
},
// ==================== 偏好设置 ====================
setWeekStart(e) { setWeekStart(e) {
this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) }); this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) });
this.saveSettings(); this.saveSettings();
@@ -79,20 +234,13 @@ Page({
}, },
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', '/pages/fortune/fortune'];
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' });
}
});
} }
}); });
+77 -6
View File
@@ -2,14 +2,85 @@
<!-- 用户信息 --> <!-- 用户信息 -->
<view class="card"> <view class="card">
<view class="section-title">用户信息</view> <view class="section-title">用户信息</view>
<view class="flex items-center gap-2" wx:if="{{userInfo}}"> <view class="flex items-center gap-2 justify-between" wx:if="{{loggedIn}}">
<image src="{{userInfo.avatarUrl}}" class="avatar" mode="aspectFill"/> <view class="flex items-center gap-2">
<view> <image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
<text class="text-base font-medium">{{userInfo.nickName}}</text> <view class="avatar avatar-placeholder" wx:else>
<text class="text-xs text-muted block">已登录</text> <text>客</text>
</view>
<view>
<text class="text-base font-medium">{{userInfo.nickname || '微信用户'}}</text>
<text class="text-xs text-muted block">已登录,档案云端同步</text>
</view>
</view>
<view class="btn-ghost text-muted" bindtap="logout">退出</view>
</view>
<block wx:else>
<view class="text-sm text-muted mb-2">登录后可将生辰档案同步到云端,换设备不丢失</view>
<view class="btn-primary text-center" bindtap="login">{{logging ? '登录中…' : '微信登录'}}</view>
</block>
</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> </view>
<view class="btn-primary" wx:else bindtap="login">微信登录</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>
<!-- 偏好设置 --> <!-- 偏好设置 -->
+45
View File
@@ -15,6 +15,51 @@
border-radius: 50%; border-radius: 50%;
} }
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #C41E3A;
color: #FFFFFF;
font-size: 36rpx;
}
/* 生辰档案 */
.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;
}
.setting-item { .setting-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+289 -173
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,36 @@ 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() { onPullDownRefresh() {
this.loadWishes().then(() => { const { currentTreeIndex, trees } = this.data;
const tree = trees[currentTreeIndex];
if (tree) {
this.loadTreeWishes(currentTreeIndex, true).then(() => {
wx.stopPullDownRefresh();
});
} else {
wx.stopPullDownRefresh(); 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,137 +99,248 @@ 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 下竖屏会左右对称裁剪,横向需要按比例展开
try { transformSlot(slot) {
const res = await request({ const r = this.vh / this.vw;
url: '/api/wish/tree/1/wishes', const x = slot.x * r - (r - 1) * 50;
method: 'GET',
});
if (res.code === 0) {
const wishes = this.formatWishes(res.data.list || []);
this.setData({
wishes,
recentWishes: wishes.slice(0, 8),
loading: false,
});
}
} catch (err) {
console.error('加载许愿失败:', err);
const wishes = this.formatWishes(this.getMockWishes());
this.setData({
wishes,
recentWishes: wishes.slice(0, 8),
loading: false,
});
}
},
// 格式化许愿数据
formatWishes(list) {
const colorKeys = Object.keys(TAG_COLORS);
return list.map((wish, index) => {
const slot = this.pickSlot(index);
const colorKey = colorKeys[index % colorKeys.length];
const colors = TAG_COLORS[colorKey];
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,
};
});
},
// 选择挂载点
pickSlot(index) {
const base = CANOPY_SLOTS[index % CANOPY_SLOTS.length];
const wrap = Math.floor(index / CANOPY_SLOTS.length);
if (wrap === 0) return base;
const jitterX = ((wrap * 37) % 9) - 4;
const jitterY = ((wrap * 53) % 9) - 4;
return { return {
x: Math.min(92, Math.max(8, base.x + jitterX)), x: Math.min(96, Math.max(4, x)),
y: Math.min(58, Math.max(10, base.y + jitterY)), 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 {
const res = await request({
url: `/api/wish/tree/${tree.id}/wishes`,
method: 'GET',
});
if (res.code === 0) {
list = res.data.list || [];
}
} catch (err) {
console.error('加载许愿失败:', err);
list = this.getMockWishes();
}
// 按时间倒序(新数据在前)
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,
};
});
// 其余以弹幕飘过
const danmaku = list.slice(MAX_HANG).map((w, i) => ({
id: w.id,
content: w.content,
author: w.author || '匿名',
type: w.type,
top: 12 + (i % 6) * 7,
duration: 16 + w.content.length * 0.4,
delay: -(i * 2.1),
}));
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,
currentDanmaku: cache.danmaku,
});
},
// 格式化许愿
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) {
const base = CANOPY_SLOTS[index % CANOPY_SLOTS.length];
const wrap = Math.floor(index / CANOPY_SLOTS.length);
if (wrap === 0) return base;
const jitterX = ((wrap * 37) % 7) - 3;
const jitterY = ((wrap * 53) % 7) - 3;
return {
x: Math.min(75, Math.max(25, base.x + jitterX)),
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({
products: [
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
],
});
} }
this.setData({
products: [
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
],
});
}, },
// 打开许愿弹窗 // 打开/关闭许愿弹窗
openCreateModal() { openCreateModal() {
this.setData({ showCreateModal: true }); this.setData({ showCreateModal: true });
}, },
// 关闭许愿弹窗
closeCreateModal() { closeCreateModal() {
this.setData({ this.setData({
showCreateModal: false, showCreateModal: false,
wishContent: '', wishContent: '',
wishType: 'free', wishType: 'free',
@@ -225,44 +348,47 @@ 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;
} }
if (wishType === 'paid' && !selectedProduct) { if (wishType === 'paid' && !selectedProduct) {
wx.showToast({ title: '请选择许愿商品', icon: 'none' }); wx.showToast({ title: '请选择许愿商品', icon: 'none' });
return; return;
} }
try { try {
if (wishType === 'paid') { if (wishType === 'paid') {
const orderRes = await request({ const orderRes = await request({
@@ -272,10 +398,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 +411,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 +438,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 +450,6 @@ Page({
}); });
}, },
// 查看许愿详情
viewWishDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({
url: `/pages/wish-detail/wish-detail?id=${id}`,
});
},
// 分享 // 分享
onShareAppMessage() { onShareAppMessage() {
return { return {
+99 -76
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" 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;"
class="firefly" ></view>
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>
<!-- 背景氛围:花瓣 --> <!-- 多树分屏轮播 -->
<view class="petals"> <swiper
<view class="tree-swiper"
wx:for="{{petals}}" current="{{currentTreeIndex}}"
wx:key="index" bindchange="onSwiperChange"
class="petal" circular="{{trees.length > 1}}"
style="left: {{item.left}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;" >
></view> <swiper-item wx:for="{{trees}}" wx:key="id">
</view> <view class="tree-page">
<!-- 背景图撑满全屏 -->
<image
src="{{item.imageUrl}}"
class="tree-bg"
mode="aspectFill"
/>
<!-- 边缘暗角,让树从中心发光 --> <!-- 树上的许愿便签(最新30条) -->
<view class="vignette"></view> <view
wx:for="{{item.hangWishes}}"
<!-- 主内容 --> wx:for-item="wish"
<view class="main-content"> wx:key="id"
<!-- 头部 --> class="wish-tag {{wish.isNew ? 'new' : ''}}"
<view class="header"> style="left: {{wish.x}}%; top: {{wish.y}}%;"
<text class="subtitle">MAKE A WISH</text> catchtap="openDetail"
<text class="title">许愿树</text> data-wish="{{wish}}"
<text class="description">月色微凉,灯火摇曳。写下心中所愿,挂上枝头,静待花开成真。</text> >
<text class="wish-count">已有 <text class="count">{{wishes.length}}</text> 个心愿挂满枝头</text> <view class="tag-sway" style="animation-duration: {{wish.swayDuration}}s; animation-delay: {{wish.swayDelay}}s;">
</view> <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}};">
<view class="tree-container"> <text class="tag-text">{{wish.content}}</text>
<image <text class="tag-author">—— {{wish.author}}</text>
src="/images/wish-tree.jpg" </view>
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> </view>
</view> </swiper-item>
</swiper>
<!-- 最近的心愿 --> <!-- 弹幕层(超出50条的心愿缓缓飘过) -->
<view class="recent-section"> <view class="danmaku-layer">
<text class="section-title">最 近 的 心 愿</text> <view
<scroll-view class="recent-list" scroll-x enhanced show-scrollbar="{{false}}"> wx:for="{{currentDanmaku}}"
<view wx:key="id"
wx:for="{{recentWishes}}" class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
wx:key="id" style="top: {{item.top}}%; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
class="recent-item" >{{item.content}} · {{item.author}}</view>
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>
<!-- 许愿按钮 --> <!-- 边缘暗角 -->
<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
wx:for="{{trees}}"
wx:key="id"
class="dot {{currentTreeIndex === index ? 'active' : ''}}"
></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}}"
+274 -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,297 @@
.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: infinite;
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 +451,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 +485,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 +497,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 +517,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 +557,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 +570,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 +593,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 +607,13 @@
opacity: 1; opacity: 1;
} }
} }
/* 弹幕:从屏幕右侧飘到左侧 */
@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
};
+21 -3
View File
@@ -1,5 +1,23 @@
// 网络请求封装 // 网络请求封装
const BASE_URL = 'http://localhost:8080'; // 开发环境 // 按小程序运行环境自动切换后端地址:
// - develop(开发者工具):本地开发服务
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
const API_HOSTS = {
develop: 'http://localhost:8080',
trial: 'https://lunar.neatcn.com',
release: 'https://lunar.neatcn.com',
};
function resolveBaseUrl() {
try {
const envVersion = wx.getAccountInfoSync().miniProgram.envVersion;
return API_HOSTS[envVersion] || API_HOSTS.release;
} catch (e) {
return API_HOSTS.release;
}
}
const BASE_URL = resolveBaseUrl();
// 请求拦截器 // 请求拦截器
function request(options) { function request(options) {
@@ -20,8 +38,8 @@ function request(options) {
if (res.statusCode === 200) { if (res.statusCode === 200) {
resolve(res.data); resolve(res.data);
} else if (res.statusCode === 401) { } else if (res.statusCode === 401) {
// 未授权,跳转登录 // 未授权:清除失效 token,由页面引导重新登录
wx.navigateTo({ url: '/pages/settings/settings' }); wx.removeStorageSync('token');
reject(new Error('未授权')); reject(new Error('未授权'));
} else { } else {
reject(new Error(res.data.msg || '请求失败')); reject(new Error(res.data.msg || '请求失败'));
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

+51 -9
View File
@@ -1,16 +1,42 @@
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"
"github.com/gouki/lunar-server/internal/handler" "github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware" "github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
"github.com/joho/godotenv"
) )
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed assets/images
var assetsFS embed.FS
func main() { func main() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
_ = godotenv.Load(".env.local", "../.env.local", ".env")
// 将构建时注入的版本信息传递给 handler
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
// 加载配置 // 加载配置
cfg := config.Load() cfg := config.Load()
@@ -28,16 +54,21 @@ func main() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
// 创建路由 // 创建路由gin.Default 自带访问日志与 Recovery
r := gin.Default() r := gin.Default()
// 中间件 // 中间件
r.Use(middleware.CORS()) r.Use(middleware.CORS())
r.Use(middleware.Logger())
// 静态文件 // 静态文件与管理后台模板
r.Static("/static", "./web/static") r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html") r.StaticFile("/", "./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")
@@ -53,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)
} }
// 支付相关 // 支付相关
@@ -87,13 +122,20 @@ func main() {
// 管理后台路由(Inertia.js // 管理后台路由(Inertia.js
admin := r.Group("/admin") admin := r.Group("/admin")
admin.Use(middleware.AdminAuth())
{ {
admin.GET("/", handler.AdminDashboard) // 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
admin.GET("/users", handler.AdminUsers) admin.POST("/login", handler.AdminLogin)
admin.GET("/orders", handler.AdminOrders) admin.POST("/logout", handler.AdminLogout)
admin.GET("/wishes", handler.AdminWishes)
admin.GET("/settings", handler.AdminSettings) authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/settings", handler.AdminSettings)
}
} }
// 启动服务器 // 启动服务器
+16 -4
View File
@@ -10,11 +10,13 @@ type Config struct {
Redis RedisConfig Redis RedisConfig
JWT JWTConfig JWT JWTConfig
Wechat WechatConfig Wechat WechatConfig
Admin AdminConfig
} }
type ServerConfig struct { type ServerConfig struct {
Port string Port string
Env string Env string
CORSOrigins string
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -43,11 +45,18 @@ type WechatConfig struct {
MchID string MchID string
} }
// AdminConfig 管理后台配置
type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录
Password string
}
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"), Port: getEnv("SERVER_PORT", "8080"),
Env: getEnv("SERVER_ENV", "development"), Env: getEnv("SERVER_ENV", "development"),
CORSOrigins: getEnv("CORS_ORIGINS", ""),
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"), Host: getEnv("DB_HOST", "localhost"),
@@ -71,6 +80,9 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""), PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""), MchID: getEnv("WECHAT_PAY_MCHID", ""),
}, },
Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""),
},
} }
} }
+11 -3
View File
@@ -41,6 +41,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 +57,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 +73,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
+81 -20
View File
@@ -1,47 +1,108 @@
package handler package handler
import ( import (
"crypto/subtle"
"net/http" "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/service"
) )
// adminPage 统一的管理后台页面渲染(补充 Inertia 需要的 url 属性)
func adminPage(c *gin.Context, title, page string) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": title,
"page": page,
"url": c.Request.URL.Path,
})
}
// AdminLogin 管理员登录:密码正确签发 role=admin 的 JWT
func AdminLogin(c *gin.Context) {
cfg := config.Load()
if cfg.Admin.Password == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": 503,
"msg": "后台登录未启用",
})
return
}
var req struct {
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "参数错误",
})
return
}
// 恒定时间比较,防时序攻击
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(cfg.Admin.Password)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "密码错误",
})
return
}
userService := service.NewUserService()
token, err := userService.GenerateAdminToken(cfg.JWT.Secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "生成token失败",
})
return
}
// 同时写入 HttpOnly Cookie,支持浏览器直接导航后台页面
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(service.AdminTokenCookie, token, 12*3600, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"token": token,
},
})
}
// AdminLogout 管理员登出:清除会话 Cookie
func AdminLogout(c *gin.Context) {
cfg := config.Load()
c.SetCookie(service.AdminTokenCookie, "", -1, "/admin", "", cfg.Server.Env == "production", true)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
})
}
// AdminDashboard 管理后台首页 // AdminDashboard 管理后台首页
func AdminDashboard(c *gin.Context) { func AdminDashboard(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "管理后台", "dashboard")
"title": "管理后台",
"page": "dashboard",
})
} }
// AdminUsers 用户管理 // AdminUsers 用户管理
func AdminUsers(c *gin.Context) { func AdminUsers(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "用户管理", "users")
"title": "用户管理",
"page": "users",
})
} }
// AdminOrders 订单管理 // AdminOrders 订单管理
func AdminOrders(c *gin.Context) { func AdminOrders(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "订单管理", "orders")
"title": "订单管理",
"page": "orders",
})
} }
// AdminWishes 许愿管理 // AdminWishes 许愿管理
func AdminWishes(c *gin.Context) { func AdminWishes(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "许愿管理", "wishes")
"title": "许愿管理",
"page": "wishes",
})
} }
// AdminSettings 系统设置 // AdminSettings 系统设置
func AdminSettings(c *gin.Context) { func AdminSettings(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{ adminPage(c, "系统设置", "settings")
"title": "系统设置",
"page": "settings",
})
} }
+10 -7
View File
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
}) })
} }
// GetOrderDetail 获取订单详情 // GetOrderDetail 获取订单详情(仅限本人订单)
func GetOrderDetail(c *gin.Context) { func GetOrderDetail(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByID(uint(orderID)) order, err := orderService.GetOrderByID(uint(orderID))
if err != nil { if err != nil || order.UserID != userID.(uint) {
// 不存在与无权访问统一返回 404,避免枚举他人订单
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
"msg": "订单不存在", "msg": "订单不存在",
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
}) })
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人待支付订单)
func CancelOrder(c *gin.Context) { func CancelOrder(c *gin.Context) {
userID, _ := c.Get("userID")
id := c.Param("id") id := c.Param("id")
orderID, _ := strconv.Atoi(id) orderID, _ := strconv.Atoi(id)
orderService := service.NewOrderService() orderService := service.NewOrderService()
if err := orderService.CancelOrder(uint(orderID)); err != nil { if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"code": 500, "code": 400,
"msg": "取消订单失败", "msg": err.Error(),
}) })
return return
} }
+103 -38
View File
@@ -1,14 +1,20 @@
package handler package handler
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model" "github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CreateOrder 创建订单 // CreateOrder 创建订单
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
func CreateOrder(c *gin.Context) { func CreateOrder(c *gin.Context) {
userID, exists := c.Get("userID") userID, exists := c.Get("userID")
if !exists { if !exists {
@@ -20,11 +26,9 @@ func CreateOrder(c *gin.Context) {
} }
var req struct { var req struct {
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员 Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
ProductID uint `json:"productId" binding:"required"` // 商品ID ProductID uint `json:"productId" binding:"required"` // 商品ID
ProductName string `json:"productName" binding:"required"` // 商品名称 Content string `json:"content"` // 许愿内容(许愿类型需要)
Amount int `json:"amount" binding:"required"` // 金额(分)
Content string `json:"content"` // 许愿内容(许愿类型需要)
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
return return
} }
orderService := service.NewOrderService() // 服务端定价:按商品 ID 查库取价格,防止客户端篡改金额
wishService := service.NewWishService()
product, err := wishService.GetWishProductByID(req.ProductID)
if err != nil || product.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "商品不存在或已下架",
})
return
}
if req.Type == "wish" {
if req.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "请输入许愿内容",
})
return
}
if len([]rune(req.Content)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "许愿内容超过 100 字限制",
})
return
}
} else {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "不支持的订单类型",
})
return
}
orderService := service.NewOrderService()
order := &model.Order{ order := &model.Order{
UserID: userID.(uint), UserID: userID.(uint),
Type: req.Type, Type: req.Type,
ProductID: req.ProductID, ProductID: product.ID,
ProductName: req.ProductName, ProductName: product.Name,
Amount: req.Amount, Amount: product.Price, // 金额以商品表为准
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
} }
if err := orderService.CreateOrder(order); err != nil { if err := orderService.CreateOrder(order); err != nil {
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
return return
} }
// 如果是许愿类型,创建许愿记录
if req.Type == "wish" && req.Content != "" {
wishService := service.NewWishService()
wish := &model.Wish{
UserID: userID.(uint),
TreeID: 1, // 默认许愿树
Content: req.Content,
Type: "paid",
Status: 1,
}
wishService.CreateWish(wish)
}
// 创建微信支付订单 // 创建微信支付订单
payParams, err := orderService.CreateWechatPayOrder(order, "") payParams, err := orderService.CreateWechatPayOrder(order, "")
if err != nil { if err != nil {
@@ -87,12 +112,29 @@ func CreateOrder(c *gin.Context) {
}) })
} }
// PayNotify 支付回调 // paySignOf 计算支付回调签名:HMAC-SHA256(orderNo|transactionId, APIKEY)
// 过渡方案:真实微信支付 V3 回调验签接入前的内部协议
func paySignOf(orderNo, transactionID, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(orderNo + "|" + transactionID))
return hex.EncodeToString(mac.Sum(nil))
}
// PayNotify 支付回调:必须携带 X-Pay-Sign 签名头,验签通过且幂等处理
func PayNotify(c *gin.Context) { func PayNotify(c *gin.Context) {
// TODO: 验证微信支付回调签名 cfg := config.Load()
if cfg.Wechat.PayKey == "" {
// 未配置支付密钥时拒绝一切回调,避免裸奔
c.JSON(http.StatusServiceUnavailable, gin.H{
"code": "FAIL",
"msg": "支付服务未配置",
})
return
}
var req struct { var req struct {
OrderNo string `json:"orderNo"` OrderNo string `json:"orderNo" binding:"required"`
TransactionID string `json:"transactionId"` TransactionID string `json:"transactionId" binding:"required"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -104,15 +146,32 @@ func PayNotify(c *gin.Context) {
return return
} }
if req.Status == "SUCCESS" { // 验签:恒定时间比较,防时序攻击
orderService := service.NewOrderService() sign := c.GetHeader("X-Pay-Sign")
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil { expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
c.JSON(http.StatusInternalServerError, gin.H{ if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
"code": "FAIL", c.JSON(http.StatusUnauthorized, gin.H{
"msg": "处理失败", "code": "FAIL",
}) "msg": "签名验证失败",
return })
} return
}
if req.Status != "SUCCESS" {
c.JSON(http.StatusOK, gin.H{
"code": "SUCCESS",
"msg": "OK",
})
return
}
orderService := service.NewOrderService()
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": "FAIL",
"msg": "处理失败",
})
return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
}) })
} }
// GetPayStatus 获取支付状态 // GetPayStatus 获取支付状态(支持订单号或订单 ID
func GetPayStatus(c *gin.Context) { func GetPayStatus(c *gin.Context) {
orderID := c.Param("orderId") param := c.Param("orderId")
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByOrderNo(orderID) order, err := orderService.GetOrderByOrderNo(param)
if err != nil {
// 兼容传数字 ID 的调用方
if id, convErr := strconv.Atoi(param); convErr == nil {
order, err = orderService.GetOrderByID(uint(id))
}
}
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
+207 -31
View File
@@ -1,7 +1,13 @@
package handler package handler
import ( import (
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/url"
"strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/config"
@@ -9,7 +15,71 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// UserLogin 用户登录 // code2Session 调用微信 jscode2session 接口换取 openid
// session_key 仅保存在服务端,绝不下发给客户端
func code2Session(code string) (openID, unionID string, err error) {
cfg := config.Load()
if cfg.Wechat.AppID == "" || cfg.Wechat.AppSecret == "" {
return "", "", fmt.Errorf("wechat appid/secret not configured")
}
query := url.Values{
"appid": {cfg.Wechat.AppID},
"secret": {cfg.Wechat.AppSecret},
"js_code": {code},
"grant_type": {"authorization_code"},
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.weixin.qq.com/sns/jscode2session?" + query.Encode())
if err != nil {
return "", "", fmt.Errorf("jscode2session request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", "", fmt.Errorf("read jscode2session response failed: %w", err)
}
var result struct {
OpenID string `json:"openid"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("parse jscode2session response failed: %w", err)
}
if result.ErrCode != 0 || result.OpenID == "" {
return "", "", fmt.Errorf("wechat auth failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return result.OpenID, result.UnionID, nil
}
// upsertUser 根据 openid 获取用户,不存在则创建
func upsertUser(openID, unionID string) (*model.User, error) {
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil {
return nil, err
}
if user == nil {
user = &model.User{
OpenID: openID,
UnionID: unionID,
Nickname: "微信用户",
Status: 1,
}
if err := userService.CreateUser(user); err != nil {
return nil, err
}
}
return user, nil
}
// UserLogin 用户登录(小程序 wx.login 的 code 换取 token
func UserLogin(c *gin.Context) { func UserLogin(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -23,38 +93,35 @@ func UserLogin(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid openID, unionID, err := code2Session(req.Code)
// 这里模拟返回
openID := "mock_openid_" + req.Code
userService := service.NewUserService()
user, err := userService.GetUserByOpenID(openID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusUnauthorized, gin.H{
"code": 500, "code": 401,
"msg": "服务器错误", "msg": "微信登录失败",
}) })
return return
} }
// 如果用户不存在,创建新用户 user, err := upsertUser(openID, unionID)
if user == nil { if err != nil {
user = &model.User{ c.JSON(http.StatusInternalServerError, gin.H{
OpenID: openID, "code": 500,
Nickname: "微信用户", "msg": "创建用户失败",
Status: 1, })
} return
if err := userService.CreateUser(user); err != nil { }
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, // 禁用用户不允许登录
"msg": "创建用户失败", if user.Status != 1 {
}) c.JSON(http.StatusForbidden, gin.H{
return "code": 403,
} "msg": "账号已被禁用",
})
return
} }
// 生成 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService()
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret) token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -217,7 +284,112 @@ func UpdateUserProfile(c *gin.Context) {
}) })
} }
// WechatAuth 微信授权 // 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 属敏感凭证不下发)
func WechatAuth(c *gin.Context) { func WechatAuth(c *gin.Context) {
var req struct { var req struct {
Code string `json:"code" binding:"required"` Code string `json:"code" binding:"required"`
@@ -231,16 +403,20 @@ func WechatAuth(c *gin.Context) {
return return
} }
// TODO: 调用微信接口获取 openid 和 session_key openID, _, err := code2Session(req.Code)
// 这里模拟返回 if err != nil {
openID := "mock_openid_" + req.Code c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "微信授权失败",
})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"msg": "success", "msg": "success",
"data": gin.H{ "data": gin.H{
"openid": openID, "openid": openID,
"sessionKey": "mock_session_key",
}, },
}) })
} }
+12 -21
View File
@@ -2,11 +2,18 @@ package handler
import ( import (
"net/http" "net/http"
"os" "runtime"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// VersionInfo 版本信息 // VersionInfo 版本信息
type VersionInfo struct { type VersionInfo struct {
Version string `json:"version"` Version string `json:"version"`
@@ -17,26 +24,10 @@ type VersionInfo struct {
// GetVersion 获取服务器版本信息 // GetVersion 获取服务器版本信息
func GetVersion(c *gin.Context) { func GetVersion(c *gin.Context) {
// 从环境变量读取版本信息(由 CI 构建时注入)
version := os.Getenv("APP_VERSION")
if version == "" {
version = "dev"
}
buildTime := os.Getenv("APP_BUILD_TIME")
if buildTime == "" {
buildTime = "unknown"
}
commitSha := os.Getenv("APP_COMMIT_SHA")
if commitSha == "" {
commitSha = "unknown"
}
c.JSON(http.StatusOK, VersionInfo{ c.JSON(http.StatusOK, VersionInfo{
Version: version, Version: Version,
BuildTime: buildTime, BuildTime: BuildTime,
CommitSha: commitSha, CommitSha: CommitSha,
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取 GoVersion: runtime.Version(),
}) })
} }
+87 -33
View File
@@ -9,12 +9,41 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// CORS 跨域中间件 // CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
cfg := config.Load()
origins := strings.TrimSpace(cfg.Server.CORSOrigins)
allowAll := origins == ""
allowed := map[string]bool{}
if !allowAll {
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed[o] = true
}
}
}
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.GetHeader("Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if origin != "" {
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if allowed[origin] {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
} else {
// 非白名单源:不输出 CORS 头,浏览器会拦截跨域请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Pay-Sign")
}
if c.Request.Method == "OPTIONS" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent) c.AbortWithStatus(http.StatusNoContent)
@@ -25,40 +54,38 @@ func CORS() gin.HandlerFunc {
} }
} }
// Logger 日志中间件 // bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
func Logger() gin.HandlerFunc { func bearerToken(c *gin.Context) (string, bool) {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { authHeader := c.GetHeader("Authorization")
return "" if authHeader == "" {
}) c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return "", false
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return "", false
}
return parts[1], true
} }
// Auth JWT认证中间件 // Auth JWT认证中间件
func Auth() gin.HandlerFunc { func Auth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") tokenString, ok := bearerToken(c)
if authHeader == "" { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "未授权",
})
c.Abort()
return return
} }
// 解析 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "token格式错误",
})
c.Abort()
return
}
tokenString := parts[1]
// 解析 token
cfg := config.Load() cfg := config.Load()
userService := service.NewUserService() userService := service.NewUserService()
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret) userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
return return
} }
// 将用户ID存入上下文
c.Set("userID", userID) c.Set("userID", userID)
c.Next() c.Next()
} }
} }
// AdminAuth 管理员认证中间件 // AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWTAuthorization 头或 HttpOnly Cookie
func AdminAuth() gin.HandlerFunc { func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// TODO: 实现管理员认证 tokenString := ""
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// 浏览器导航场景:登录时写入的 HttpOnly Cookie
tokenString, _ = c.Cookie(service.AdminTokenCookie)
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
cfg := config.Load()
userService := service.NewUserService()
if !userService.IsAdminToken(tokenString, cfg.JWT.Secret) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"msg": "需要管理员权限",
})
c.Abort()
return
}
c.Next() c.Next()
} }
} }
+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"`
+58 -8
View File
@@ -3,7 +3,9 @@ package service
import ( import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"log"
"math/rand" "math/rand"
"time" "time"
@@ -98,9 +100,18 @@ func (s *OrderService) UpdateOrderStatus(orderNo, status string) error {
return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error return s.db.Model(&model.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error
} }
// CancelOrder 取消订单 // CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
func (s *OrderService) CancelOrder(id uint) error { func (s *OrderService) CancelOrder(userID, id uint) error {
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error result := s.db.Model(&model.Order{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, "pending").
Update("status", "cancelled")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("订单不存在或当前状态不可取消")
}
return nil
} }
// CheckOrderExpired 检查订单是否过期 // CheckOrderExpired 检查订单是否过期
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// HandlePayNotify 处理支付回调 // HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error { func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
// 更新订单状态 order, err := s.GetOrderByOrderNo(orderNo)
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil { if err != nil {
return err return fmt.Errorf("order not found: %w", err)
} }
// TODO: 处理业务逻辑(如创建许愿、开通会员等) // 幂等:已支付直接返回成功
if order.Status == "paid" {
return nil
}
if order.Status != "pending" {
return fmt.Errorf("order %s in unexpected status %s", orderNo, order.Status)
}
// 条件更新防止并发重复入账
updates := map[string]interface{}{
"status": "paid",
"pay_time": time.Now(),
"remark": order.Remark + " [tx:" + transactionID + "]",
}
result := s.db.Model(&model.Order{}).
Where("order_no = ? AND status = ?", orderNo, "pending").
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 并发下已被其他回调处理,视为幂等成功
return nil
}
// 支付成功后才创建付费许愿
if order.Type == "wish" && order.Remark != "" {
wishService := NewWishService()
wish := &model.Wish{
UserID: order.UserID,
TreeID: 1, // 默认祈福树
Content: order.Remark,
Type: "paid",
Status: 1,
}
if err := wishService.CreateWish(wish); err != nil {
// 许愿创建失败不回滚支付状态,由后台补处理
log.Printf("create paid wish for order %s failed: %v", orderNo, err)
}
}
return nil return nil
} }
+71 -2
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{
@@ -93,9 +123,48 @@ func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
} }
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userID := uint(claims["user_id"].(float64)) // 管理员 token 不绑定具体用户,不能当普通用户 token 使用
return userID, nil if role, _ := claims["role"].(string); role == "admin" {
return 0, errors.New("admin token")
}
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
return 0, errors.New("invalid token claims")
}
return uint(userIDFloat), nil
} }
return 0, errors.New("invalid token") return 0, errors.New("invalid token")
} }
// AdminTokenCookie 管理员会话 Cookie 名
const AdminTokenCookie = "lunar_admin_token"
// GenerateAdminToken 签发管理员 JWT12 小时过期)
func (s *UserService) GenerateAdminToken(secret string) (string, error) {
claims := jwt.MapClaims{
"role": "admin",
"exp": time.Now().Add(time.Hour * 12).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// IsAdminToken 校验是否为有效的管理员 JWT
func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return false
}
role, _ := claims["role"].(string)
return role == "admin"
}
+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
+6 -6
View File
@@ -7,18 +7,18 @@ echo "Building Lunar Server..."
# 创建输出目录 # 创建输出目录
mkdir -p bin mkdir -p bin
# 编译 Go 后端 # 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
echo "Building Go backend..." echo "Building Go backend..."
go build -o bin/lunar-server cmd/main.go go build -o bin/server cmd/main.go
# 编译前端(如果存在 # 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建
if [ -d "web" ]; then if [ -f "web/package.json" ]; then
echo "Building frontend..." echo "Building frontend..."
cd web cd web
npm install npm ci
npm run build npm run build
cd .. cd ..
fi fi
echo "Build complete!" echo "Build complete!"
echo "Output: bin/lunar-server" echo "Output: bin/server"
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .title }} - 祈福小助手管理后台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/@inertiajs/vue3@1.0.0/dist/index.umd.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-100">
<div id="app" data-page='{"component":"{{ .page }}","props":{},"url":"{{ .url }}","version":""}'></div>
<script>
const { createApp, h } = Vue;
const { createInertiaApp } = Inertia;
createInertiaApp({
resolve: name => {
const pages = {
dashboard: () => import('/static/js/pages/Dashboard.js'),
users: () => import('/static/js/pages/Users.js'),
orders: () => import('/static/js/pages/Orders.js'),
wishes: () => import('/static/js/pages/Wishes.js'),
settings: () => import('/static/js/pages/Settings.js'),
};
return pages[name] ? pages[name]() : pages.dashboard();
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
</script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
// Dashboard 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-users text-2xl text-blue-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总用户数</dt>
<dd class="text-lg font-medium text-gray-900">1,234</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-shopping-cart text-2xl text-green-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总订单数</dt>
<dd class="text-lg font-medium text-gray-900">567</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-star text-2xl text-yellow-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总许愿数</dt>
<dd class="text-lg font-medium text-gray-900">890</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-money-bill-wave text-2xl text-red-500"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">总收入</dt>
<dd class="text-lg font-medium text-gray-900">¥12,345</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8">
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">最近订单</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">用户A</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥10.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+71
View File
@@ -0,0 +1,71 @@
// Orders 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">许愿</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">普通许愿条</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥1.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+68
View File
@@ -0,0 +1,68 @@
// Settings 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">系统设置</h2>
<form class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700">许愿树最大许愿数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">免费许愿最大字数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="20">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">付费许愿最大字数</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">机器人许愿间隔(小时)</label>
<input type="number" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm" value="2">
</div>
<div>
<button type="submit" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
保存设置
</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
`
}
+69
View File
@@ -0,0 +1,69 @@
// Users 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">用户列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">昵称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">OpenID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">注册时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">1</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">微信用户</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">mock_openid_xxx</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">正常</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}
+69
View File
@@ -0,0 +1,69 @@
// Wishes 页面组件
export default {
template: `
<div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">许愿列表</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">1</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">愿世界和平</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">付费</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">用户A</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
`
}