Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ce5cd0f3 | ||
|
|
3cc3edeef9 | ||
|
|
8b5bfe8385 | ||
|
|
a29328f35b | ||
|
|
87f5fa2478 | ||
|
|
f7d88622e0 | ||
|
|
ac2c99c3bc | ||
|
|
4efdd20ff7 | ||
|
|
297747041a | ||
|
|
50975e0627 | ||
|
|
013e7ab99a | ||
|
|
4b003fad39 | ||
|
|
b971a34f7f | ||
|
|
fe5bed0668 | ||
|
|
f5c8d06fbe | ||
|
|
fb1bd57af0 | ||
|
|
a6bdfee548 | ||
|
|
23442017b3 | ||
|
|
320231808b | ||
|
|
fdc11bf529 | ||
|
|
d73bbf53cf | ||
|
|
ffac513b4a | ||
|
|
fdbf8307bb | ||
|
|
e92b739cb6 | ||
|
|
b507d6c154 | ||
|
|
fc7a30ad9d | ||
|
|
9e93198ef2 | ||
|
|
0deb5ba7b6 | ||
|
|
ec55a1d536 | ||
|
|
f90b7c7764 | ||
|
|
63f92501a1 | ||
|
|
b8c439deac |
@@ -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
|
||||
@@ -1,5 +1,9 @@
|
||||
# 1Panel 自动部署配置指南
|
||||
|
||||
> ⚠️ **本文档已废弃**(2026-08-09):文中“SSH 到服务器手工执行”的方式违反安全红线(服务器曾因 AI 工具持有 SSH 密钥被入侵)。
|
||||
> 现行方案请看 [PULL_DEPLOY.md](./PULL_DEPLOY.md):CI 只构建,部署由服务器本地 watcher 自动拉取,任何 AI/CI 不再 SSH。
|
||||
> 本文仅作历史参考,禁止按其中步骤操作。
|
||||
|
||||
本文档说明如何配置 1Panel 实现后端服务的全自动部署。
|
||||
|
||||
## 架构说明
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# doc79 同机拉取式部署配置(1Panel 原生方式,一次性)
|
||||
|
||||
> 安全原则:CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
|
||||
> 部署由服务器本地定时任务监听 CI 产物目录自动完成。
|
||||
> 本文档所有操作均通过 **1Panel 网页界面**或管理员本人的终端完成,不依赖 AI 远程执行。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
push main → Gitea Actions(hk 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 能力内,日志、重启、证书都在一个面板里可见可控,
|
||||
避免绕过平台管理造成"幽灵配置"。
|
||||
@@ -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: 初始版本,支持小程序和后端的版本号注入、显示和追踪
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# 拉取式部署 watcher - 从 Gitea Release 拉取最新部署包并自动部署
|
||||
# 设计原则:
|
||||
# - CI 在任意 runner(hk 等)构建,产物以 Release 附件发布到 Gitea
|
||||
# - 本脚本由服务器本地定时任务(1Panel 计划任务)每分钟调用,通过 HTTPS 拉取,
|
||||
# 任何 AI/CI 均不通过 SSH 操作服务器
|
||||
# 安装方式见 .gitea/docs/PULL_DEPLOY.md(1Panel 原生方式,一次性配置)
|
||||
#
|
||||
# 可选环境变量(建议写入 /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
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 自动部署脚本 - 部署到 1Panel Docker 容器
|
||||
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
|
||||
# 使用方法: ./deploy.sh <deploy_package_path>
|
||||
# deploy_package_path: 部署包路径(如 /ci-artifacts/server-deploy-latest.tar.gz)
|
||||
|
||||
@@ -9,6 +10,7 @@ DEPLOY_PACKAGE="$1"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-lunar-server}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar/production}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar/backups}"
|
||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8080/api/version}"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
@@ -28,6 +30,18 @@ log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 回滚函数:恢复 current.old 并重启容器
|
||||
rollback() {
|
||||
if [ -d "$DEPLOY_DIR/current.old" ]; then
|
||||
rm -rf "$DEPLOY_DIR/current"
|
||||
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
|
||||
docker restart "$CONTAINER_NAME" || log_error "回滚后容器重启失败,需人工介入"
|
||||
log_warn "已回滚到上一版本"
|
||||
else
|
||||
log_error "无可回滚版本,需人工介入"
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查部署包是否存在
|
||||
if [ ! -f "$DEPLOY_PACKAGE" ]; then
|
||||
log_error "部署包不存在: $DEPLOY_PACKAGE"
|
||||
@@ -52,7 +66,7 @@ if [ -d "$DEPLOY_DIR/current" ]; then
|
||||
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
|
||||
fi
|
||||
|
||||
# 解压新版本
|
||||
# 解压新版本到暂存目录(不碰 current,避免部署空窗)
|
||||
log_info "解压部署包..."
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
|
||||
@@ -64,15 +78,23 @@ if [ ! -d "$TEMP_DIR/deploy" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 部署新版本
|
||||
log_info "部署新版本..."
|
||||
rm -rf "$DEPLOY_DIR/current"
|
||||
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current"
|
||||
rm -rf "$TEMP_DIR"
|
||||
# 校验二进制存在
|
||||
if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
|
||||
log_error "部署包缺少 bin/server 二进制"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$TEMP_DIR/deploy/bin/server"
|
||||
|
||||
# 设置权限
|
||||
log_info "设置文件权限..."
|
||||
chmod +x "$DEPLOY_DIR/current/bin/server" || log_warn "设置可执行权限失败"
|
||||
# 部署新版本(目录级交换,接近原子)
|
||||
log_info "部署新版本..."
|
||||
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
|
||||
rm -rf "$DEPLOY_DIR/current.old"
|
||||
if [ -d "$DEPLOY_DIR/current" ]; then
|
||||
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
|
||||
fi
|
||||
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# 检查 Docker 容器是否存在
|
||||
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
|
||||
log_info "✅ 容器重启成功"
|
||||
else
|
||||
log_error "❌ 容器重启失败"
|
||||
log_error "❌ 容器重启失败,回滚到上一版本"
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 等待容器启动
|
||||
log_info "等待容器启动..."
|
||||
sleep 3
|
||||
# 健康检查:轮询 HTTP 接口确认服务真正可用
|
||||
log_info "执行健康检查: $HEALTH_URL"
|
||||
HEALTHY=0
|
||||
for i in $(seq 1 10); do
|
||||
sleep 2
|
||||
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查容器状态
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
log_info "✅ 容器运行正常"
|
||||
|
||||
# 显示容器日志(最后 20 行)
|
||||
log_info "容器日志(最后 20 行):"
|
||||
docker logs --tail 20 "$CONTAINER_NAME"
|
||||
if [ "$HEALTHY" = "1" ]; then
|
||||
log_info "✅ 健康检查通过"
|
||||
curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
|
||||
rm -rf "$DEPLOY_DIR/current.old"
|
||||
else
|
||||
log_error "❌ 容器未运行"
|
||||
log_error "容器日志:"
|
||||
docker logs --tail 50 "$CONTAINER_NAME"
|
||||
log_error "❌ 健康检查失败,回滚到上一版本"
|
||||
docker logs --tail 50 "$CONTAINER_NAME" || true
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -11,26 +11,99 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# 专属运行器 lunar-node: docker://node:22-bookworm(注册于 doc79)
|
||||
runs-on: lunar-node
|
||||
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.neatcn.com/gouki/action-checkout@v4
|
||||
with:
|
||||
# Need recent commit subjects for the WeChat upload remark.
|
||||
fetch-depth: 20
|
||||
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
|
||||
- name: Checkout
|
||||
env:
|
||||
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$REPO_URL"
|
||||
git fetch -q --depth 20 origin "$REF_NAME"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log --oneline -3
|
||||
|
||||
- name: Prepare Node toolchain
|
||||
run: |
|
||||
# 优先复用 runner 宿主机缓存的工具链(工作目录持久化)
|
||||
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
|
||||
mkdir -p "$TOOLDIR"
|
||||
if [ -x "$TOOLDIR/node/bin/node" ] && "$TOOLDIR/node/bin/node" --version >/dev/null 2>&1; then
|
||||
echo "复用缓存 Node 工具链"
|
||||
else
|
||||
# 清理旧缓存:Alpine busybox mv 遇已存在目录会合并而非替换,
|
||||
# 残留的旧版 node 会污染环境导致 symbol not found
|
||||
rm -rf "$TOOLDIR"
|
||||
mkdir -p "$TOOLDIR"
|
||||
# 探测 libc:Alpine(musl) 用 unofficial musl 构建,其余用官方 glibc 构建(tar.gz,busybox tar 不支持 xz)
|
||||
# 关键教训:unofficial-builds 在亚洲带宽极低,wget --timeout 只限单次读取,
|
||||
# 慢速断流仍返回成功,产生截断的 gzip(解压出损坏二进制报 symbol not found)。
|
||||
# 必须用 sha256 硬校验,不匹配则换源重试。
|
||||
if ldd --version 2>&1 | grep -qi musl || [ ! -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
|
||||
NODE_DIST="node-v22.12.0-linux-x64-musl"
|
||||
NODE_SHA="8d96de0293fc3978d09e2a3fee68c66bca4efcca90f52105e6f7463636e97308"
|
||||
# 优先 npmmirror(国内/亚洲快),unofficial-builds 作为后备
|
||||
URLS="https://registry.npmmirror.com/-/binary/node-unofficial-builds/v22.12.0/$NODE_DIST.tar.gz
|
||||
https://unofficial-builds.nodejs.org/download/release/v22.12.0/$NODE_DIST.tar.gz"
|
||||
else
|
||||
NODE_DIST="node-v22.12.0-linux-x64"
|
||||
NODE_SHA="e05a4d65232ae2b27b3d77da2e368522fb46b923335b8e0d5f77624c32484044"
|
||||
URLS="https://nodejs.org/dist/v22.12.0/$NODE_DIST.tar.gz
|
||||
https://mirrors.aliyun.com/nodejs-release/v22.12.0/$NODE_DIST.tar.gz"
|
||||
fi
|
||||
DL_OK=""
|
||||
for url in $URLS; do
|
||||
echo "尝试下载: $url"
|
||||
if wget -t 2 -T 120 -q -O "$TOOLDIR/node.tgz" "$url"; then
|
||||
# sha256 硬校验(busybox 有 sha256sum):拦截截断/错误页等假文件
|
||||
if [ "$(sha256sum "$TOOLDIR/node.tgz" | cut -d' ' -f1)" = "$NODE_SHA" ]; then
|
||||
echo "下载成功且校验通过"
|
||||
DL_OK=1
|
||||
break
|
||||
fi
|
||||
echo "sha256 校验失败,文件可能截断,尝试下一源"
|
||||
else
|
||||
echo "wget 下载失败,尝试下一源"
|
||||
fi
|
||||
rm -f "$TOOLDIR/node.tgz"
|
||||
done
|
||||
test -n "$DL_OK" || { echo "::error::所有下载源均失败或校验不通过"; exit 1; }
|
||||
tar -C "$TOOLDIR" -xzf "$TOOLDIR/node.tgz"
|
||||
mv "$TOOLDIR/$NODE_DIST" "$TOOLDIR/node"
|
||||
rm -f "$TOOLDIR/node.tgz"
|
||||
# musl 版 node 动态链接 libstdc++/libgcc_s,Alpine 精简容器缺失时补装
|
||||
if [ "$NODE_DIST" = "node-v22.12.0-linux-x64-musl" ] && [ ! -e /usr/lib/libstdc++.so.6 ] && [ ! -e /lib/libstdc++.so.6 ]; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache libstdc++ libgcc || echo "::warning::libstdc++/libgcc 安装失败,node 可能无法启动"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 验证二进制可执行,失败则清缓存并报错(避免坏缓存被后续 run 复用)
|
||||
if ! "$TOOLDIR/node/bin/node" --version; then
|
||||
echo "::error::Node 二进制无法执行,请检查下载源与 libc 匹配"
|
||||
rm -rf "$TOOLDIR"
|
||||
exit 1
|
||||
fi
|
||||
echo "PATH=$TOOLDIR/node/bin:$PATH" >> "$GITEA_ENV"
|
||||
|
||||
- name: Verify Node runtime
|
||||
run: node --version
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mini
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: mini
|
||||
run: npm test -- --runInBand
|
||||
run: npm test
|
||||
|
||||
- name: Configure production API and app version
|
||||
working-directory: mini
|
||||
env:
|
||||
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
||||
MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
test -n "$MINIAPP_BUILD_NUMBER"
|
||||
@@ -39,7 +112,7 @@ jobs:
|
||||
mkdir -p ci-artifacts
|
||||
printf '%s\n' "$VERSION" > ci-artifacts/resolved-version.txt
|
||||
echo "Resolved Mini Program version for build+upload: $VERSION"
|
||||
|
||||
|
||||
# 注入版本号到 utils/version.js
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
COMMIT_SHORT="${GITEA_SHA:0:7}"
|
||||
@@ -48,7 +121,7 @@ jobs:
|
||||
* 版本号配置文件
|
||||
* 此文件由 CI 构建时自动生成,请勿手动修改
|
||||
*/
|
||||
|
||||
|
||||
module.exports = {
|
||||
version: "$VERSION",
|
||||
buildNumber: "$MINIAPP_BUILD_NUMBER",
|
||||
@@ -58,6 +131,7 @@ jobs:
|
||||
EOF
|
||||
echo "Version info injected into utils/version.js:"
|
||||
cat utils/version.js
|
||||
|
||||
- name: Materialize the upload key
|
||||
env:
|
||||
WECHAT_CI_PRIVATE_KEY: ${{ secrets.WECHAT_CI_PRIVATE_KEY }}
|
||||
@@ -65,6 +139,7 @@ jobs:
|
||||
test -n "$WECHAT_CI_PRIVATE_KEY"
|
||||
umask 077
|
||||
printf '%s' "$WECHAT_CI_PRIVATE_KEY" > "$RUNNER_TEMP/wechat-ci.key"
|
||||
|
||||
- name: Upload WeChat development version
|
||||
working-directory: mini
|
||||
env:
|
||||
@@ -72,7 +147,6 @@ jobs:
|
||||
MINIAPP_ROBOT: ${{ vars.MINIAPP_ROBOT }}
|
||||
# Prefer the version resolved before build so UI label matches WeChat upload.
|
||||
MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }}
|
||||
MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }}
|
||||
WECHAT_CI_PRIVATE_KEY_PATH: ${{ runner.temp }}/wechat-ci.key
|
||||
MINIAPP_UPLOAD_RESULT: ${{ gitea.workspace }}/mini/ci-artifacts/upload-result.json
|
||||
MINIAPP_VERSION_LABEL: ${{ gitea.sha }}
|
||||
@@ -90,31 +164,21 @@ jobs:
|
||||
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}))'
|
||||
# Normalize PEM newlines that may be flattened when stored in Secrets.
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
key_path = Path(os.environ["WECHAT_CI_PRIVATE_KEY_PATH"])
|
||||
text = key_path.read_text()
|
||||
if "\\n" in text and "BEGIN" in text:
|
||||
text = text.replace("\\n", "\n")
|
||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
||||
key_path.write_text(text)
|
||||
content = key_path.read_text()
|
||||
assert "BEGIN" in content and "PRIVATE KEY" in content, "WECHAT_CI_PRIVATE_KEY is not a PEM private key"
|
||||
print("private_key_lines=", len(content.splitlines()))
|
||||
PY
|
||||
# 用 node 处理(部分 runner 无 python3)
|
||||
node - <<'JS'
|
||||
const fs = require("fs");
|
||||
const p = process.env.WECHAT_CI_PRIVATE_KEY_PATH;
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
if (t.includes("\\n") && t.includes("BEGIN")) t = t.split("\\n").join("\n");
|
||||
t = t.split("\r\n").join("\n").trim() + "\n";
|
||||
fs.writeFileSync(p, t);
|
||||
if (!/PRIVATE KEY/.test(t)) { console.error("WECHAT_CI_PRIVATE_KEY is not a PEM private key"); process.exit(1); }
|
||||
console.log("private_key_lines=", t.split("\n").length);
|
||||
JS
|
||||
node ci/upload-ci.cjs
|
||||
test -s 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
|
||||
if: success()
|
||||
env:
|
||||
@@ -123,8 +187,8 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
@@ -134,8 +198,8 @@ jobs:
|
||||
run: |
|
||||
bash .gitea/scripts/notify.sh failure \
|
||||
"小程序开发版上传失败" \
|
||||
"请检查 Actions 日志了解失败原因"
|
||||
|
||||
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
|
||||
|
||||
- name: Remove temporary credentials
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/wechat-ci.key"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Deploy Server
|
||||
name: Build and Publish Server
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -10,72 +10,96 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 专属运行器:docker://golang:1.22-bookworm(注册于 doc79)
|
||||
runs-on: lunar-ci
|
||||
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.neatcn.com/gouki/action-checkout@v4
|
||||
with:
|
||||
fetch-depth: 20
|
||||
|
||||
- name: Verify Go runtime
|
||||
run: go version
|
||||
|
||||
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
|
||||
- name: Checkout
|
||||
env:
|
||||
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$REPO_URL"
|
||||
git fetch -q --depth 20 origin "$REF_NAME"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log --oneline -3
|
||||
|
||||
- name: Ensure curl available
|
||||
run: |
|
||||
# 精简 runner(Alpine 等)可能未预装 curl,发布 release 需要它
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl
|
||||
elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq curl
|
||||
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl
|
||||
else echo "无法安装 curl"; exit 1; fi
|
||||
fi
|
||||
curl --version | head -1
|
||||
|
||||
- name: Prepare Go toolchain
|
||||
run: |
|
||||
# 优先复用 runner 宿主机缓存的工具链(工作目录持久化)
|
||||
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
|
||||
mkdir -p "$TOOLDIR"
|
||||
if [ -x "$TOOLDIR/go/bin/go" ]; then
|
||||
echo "复用缓存 Go 工具链"
|
||||
else
|
||||
# 官方源与阿里云镜像双通道,先到先用
|
||||
wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
|
||||
"https://go.dev/dl/go1.22.10.linux-amd64.tar.gz" \
|
||||
|| wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
|
||||
"https://mirrors.aliyun.com/golang/go1.22.10.linux-amd64.tar.gz"
|
||||
rm -rf "$TOOLDIR/go"
|
||||
tar -C "$TOOLDIR" -xzf "$TOOLDIR/go.tgz"
|
||||
rm -f "$TOOLDIR/go.tgz"
|
||||
fi
|
||||
"$TOOLDIR/go/bin/go" version
|
||||
echo "LUNAR_GO=$TOOLDIR/go/bin/go" >> "$GITEA_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: server
|
||||
run: go mod download
|
||||
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
"$LUNAR_GO" mod download
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: go test ./...
|
||||
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
"$LUNAR_GO" test ./...
|
||||
|
||||
- name: Build application
|
||||
working-directory: server
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
mkdir -p bin
|
||||
|
||||
# 生成版本号(使用构建编号)
|
||||
VERSION="1.0.${GITEA_RUN_NUMBER}"
|
||||
|
||||
# 生成版本号(基线 1.1 + 构建编号,与小程序版本段对齐)
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
COMMIT_SHORT="${GITEA_SHA:0:7}"
|
||||
|
||||
|
||||
echo "Building server version: $VERSION"
|
||||
echo "Build time: $BUILD_TIME"
|
||||
echo "Commit SHA: $COMMIT_SHORT"
|
||||
|
||||
# 使用 ldflags 注入版本信息
|
||||
go build -ldflags "\
|
||||
|
||||
# ldflags 注入版本信息;目标平台为 doc79(linux/amd64)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
|
||||
-X main.Version=$VERSION \
|
||||
-X main.BuildTime=$BUILD_TIME \
|
||||
-X main.CommitSha=$COMMIT_SHORT" \
|
||||
-o bin/server cmd/main.go
|
||||
|
||||
# 同时保存版本信息到文件(用于部署时读取)
|
||||
|
||||
# 版本元数据随包分发,便于服务器侧追溯
|
||||
cat > bin/version.env <<EOF
|
||||
APP_VERSION=$VERSION
|
||||
APP_BUILD_TIME=$BUILD_TIME
|
||||
APP_COMMIT_SHA=$COMMIT_SHORT
|
||||
EOF
|
||||
|
||||
echo "Version info saved to bin/version.env:"
|
||||
cat bin/version.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
|
||||
|
||||
# 注意:不写入 GITEA_ENV,避免跨步骤/跨运行残留值干扰发布步骤
|
||||
|
||||
- name: Create deployment package
|
||||
working-directory: server
|
||||
run: |
|
||||
@@ -83,20 +107,82 @@ jobs:
|
||||
cp -r bin/ deploy/
|
||||
cp -r migrations/ deploy/
|
||||
cp -r scripts/ deploy/
|
||||
if [ -d "web/dist" ]; then
|
||||
cp -r web/dist/ deploy/public/
|
||||
# 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
|
||||
if [ -d "web" ]; then
|
||||
cp -r web/ deploy/web/
|
||||
fi
|
||||
tar -czf deploy.tar.gz deploy/
|
||||
|
||||
- name: Persist deployment package on runner host
|
||||
sha256sum deploy.tar.gz
|
||||
|
||||
- name: Publish release with deployment package
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
|
||||
run: |
|
||||
test -f server/deploy.tar.gz
|
||||
test -d /ci-artifacts
|
||||
cp server/deploy.tar.gz "/ci-artifacts/server-deploy-${{ gitea.sha }}.tar.gz"
|
||||
cp server/deploy.tar.gz /ci-artifacts/server-deploy-latest.tar.gz
|
||||
ls -la /ci-artifacts/server-deploy-latest.tar.gz
|
||||
echo "Deployment package saved on runner host: /opt/lunar/ci-artifacts/server-deploy-latest.tar.gz"
|
||||
|
||||
set -e
|
||||
# 自行计算版本号;绝不复用环境中的 SERVER_VERSION(GITEA_ENV 会跨步骤持久化,
|
||||
# 可能残留早期运行的旧值,曾导致构建 1.1.48 却发布成 1.0.48)
|
||||
unset SERVER_VERSION
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
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
|
||||
if: success()
|
||||
env:
|
||||
@@ -105,11 +191,11 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
run: |
|
||||
VERSION="1.0.${GITEA_RUN_NUMBER}"
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
@@ -118,9 +204,9 @@ jobs:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
bash .gitea/scripts/notify.sh failure \
|
||||
"后端服务部署失败" \
|
||||
"请检查 Actions 日志了解失败原因"
|
||||
|
||||
"后端构建失败" \
|
||||
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
+6
-5
@@ -55,14 +55,15 @@ server/vendor/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# 前端构建(如果 server 包含前端)
|
||||
# 前端构建产物与依赖(注意:server/web 是管理后台源码,必须入库)
|
||||
server/public/build/
|
||||
server/public/hot
|
||||
server/web/
|
||||
server/web/node_modules/
|
||||
server/web/dist/
|
||||
|
||||
# web 版万年历(独立项目,不属于本仓库)
|
||||
web/
|
||||
wishing-tree-pages/
|
||||
# web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web)
|
||||
/web/
|
||||
/wishing-tree-pages/
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"pages/divination/divination",
|
||||
"pages/solar-terms/solar-terms",
|
||||
"pages/settings/settings",
|
||||
"pages/preferences/preferences",
|
||||
"pages/bookmarks/bookmarks",
|
||||
"pages/birth-profiles/birth-profiles",
|
||||
"pages/wish-tree/wish-tree",
|
||||
"pages/wish-detail/wish-detail",
|
||||
"pages/wiki/wiki"
|
||||
|
||||
@@ -45,19 +45,22 @@ function parseSemverParts(version) {
|
||||
|
||||
/**
|
||||
* Encode a monotonic CI build number into WeChat x.y.z.
|
||||
* Keeps package.json major; puts build into minor.patch with base 100.
|
||||
* Examples (major=1): build 99 → 1.0.99, build 100 → 1.1.0, build 101 → 1.1.1
|
||||
* Keeps package.json major.minor as the baseline; build number maps to patch.
|
||||
* 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) {
|
||||
throw new Error("MINIAPP_BUILD_NUMBER must be a positive integer.");
|
||||
}
|
||||
if (!Number.isInteger(major) || major < 0 || major > 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 % 100;
|
||||
const patch = buildNumber % 1000;
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
|
||||
@@ -70,12 +73,12 @@ function resolveVersion(projectPath, env = process.env) {
|
||||
}
|
||||
|
||||
const packageVersion = readPackageVersion(projectPath);
|
||||
const { major } = parseSemverParts(packageVersion);
|
||||
const { major, minor } = parseSemverParts(packageVersion);
|
||||
|
||||
const buildRaw = (env.MINIAPP_BUILD_NUMBER || "").trim();
|
||||
if (buildRaw) {
|
||||
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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lunar-mini",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "祈福小助手 - 万年历微信小程序",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
+131
-56
@@ -1,78 +1,153 @@
|
||||
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({
|
||||
data: {
|
||||
gender: 'male',
|
||||
birthDate: '1990-01-01',
|
||||
birthTime: '12:00',
|
||||
ziSect: 'lateZiNextDay',
|
||||
isDemo: true,
|
||||
profiles: [],
|
||||
activeProfileId: '',
|
||||
profile: null,
|
||||
bazi: null,
|
||||
pillars: [],
|
||||
elements: [],
|
||||
elementAnalysis: {}
|
||||
elementAnalysis: {},
|
||||
glossary: GLOSSARY,
|
||||
showGlossary: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
// 尝试从本地存储恢复
|
||||
const saved = wx.getStorageSync('lunar-bazi-input');
|
||||
if (saved) {
|
||||
this.setData(saved);
|
||||
onShow() {
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
refresh() {
|
||||
const render = () => {
|
||||
const profiles = birthProfile.getProfiles();
|
||||
if (profiles.length === 0) {
|
||||
this.showDemo();
|
||||
} else {
|
||||
this.showProfile(profiles, birthProfile.getActiveProfile());
|
||||
}
|
||||
};
|
||||
// 已登录时先同步云端档案,失败静默回退本地
|
||||
if (birthProfile.isLoggedIn()) {
|
||||
birthProfile.syncFromServer().then(render).catch(render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
},
|
||||
|
||||
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 });
|
||||
},
|
||||
|
||||
onDateChange(e) {
|
||||
this.setData({ birthDate: e.detail.value });
|
||||
},
|
||||
|
||||
onTimeChange(e) {
|
||||
this.setData({ birthTime: e.detail.value });
|
||||
},
|
||||
|
||||
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 });
|
||||
/** 已绑定:展示所选档案的排盘 */
|
||||
showProfile(profiles, profile) {
|
||||
if (!profile) return;
|
||||
const [year, month, day] = profile.birthday.split('-').map(Number);
|
||||
const [hour, minute] = profile.birthTime.split(':').map(Number);
|
||||
const settings = wx.getStorageSync('lunar-settings') || {};
|
||||
const chart = buildChart({
|
||||
year, month, day, hour, minute,
|
||||
gender: profile.gender,
|
||||
ziSect: settings.ziHourSect || 'lateZiNextDay'
|
||||
});
|
||||
// 保存八字供运势页使用
|
||||
wx.setStorageSync('lunar-user-bazi', bazi);
|
||||
wx.setStorageSync('lunar-user-bazi', chart.bazi);
|
||||
this.setData({
|
||||
isDemo: false,
|
||||
profiles,
|
||||
activeProfileId: profile.id,
|
||||
profile,
|
||||
showGlossary: false,
|
||||
...chart
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.setData({ bazi: null });
|
||||
selectProfile(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const profile = this.data.profiles.find(p => p.id === id);
|
||||
if (!profile) return;
|
||||
birthProfile.setActiveProfile(id);
|
||||
this.showProfile(this.data.profiles, profile);
|
||||
},
|
||||
|
||||
toggleGlossary() {
|
||||
this.setData({ showGlossary: !this.data.showGlossary });
|
||||
},
|
||||
|
||||
goSettings() {
|
||||
wx.navigateTo({ url: '/pages/birth-profiles/birth-profiles' });
|
||||
},
|
||||
|
||||
goFortune() {
|
||||
wx.switchTab({ url: '/pages/fortune/fortune' });
|
||||
wx.navigateTo({ url: '/pages/fortune/fortune' });
|
||||
}
|
||||
});
|
||||
|
||||
+150
-97
@@ -1,104 +1,157 @@
|
||||
<view class="container">
|
||||
<!-- 输入表单 -->
|
||||
<view class="card" wx:if="{{!bazi}}">
|
||||
<view class="section-title">输入出生信息</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 class="culture-card">
|
||||
<view class="culture-title">八字 · 传统历法文化</view>
|
||||
<view class="culture-text">八字(四柱)是中国传统文化的重要组成部分,源自古人观测天象、制定历法的干支纪时体系与五行哲学思想,属于传统民俗文化与历法知识范畴,不是封建迷信。本页面仅按传统历法规则进行排盘展示与知识科普,内容供传统文化学习与娱乐参考,不构成任何人生决策建议。</view>
|
||||
</view>
|
||||
|
||||
<!-- 八字结果 -->
|
||||
<block wx:if="{{bazi}}">
|
||||
<!-- 四柱 -->
|
||||
<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-ganzhi">{{item.ganzhi}}</text>
|
||||
<text class="pillar-ten">{{item.tenStar || '日主'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 空态:示例提示 + 绑定入口 -->
|
||||
<block wx:if="{{isDemo}}">
|
||||
<view class="card empty-card">
|
||||
<view class="empty-title">你还没有绑定生辰信息</view>
|
||||
<view class="empty-desc">下方是一份示例排盘,帮助你了解八字页面的内容。在个人中心可免费绑定 3 个出生年月(本人、家人),绑定后即可查看专属排盘。</view>
|
||||
<view class="btn-primary text-center mt-2" bindtap="goSettings">去绑定我的生辰</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" 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 class="demo-banner">
|
||||
<text class="demo-tag">示例</text>
|
||||
<text class="demo-text">以下排盘基于虚构生辰(1990-08-08 10:30 男),仅演示页面效果</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 已绑定:档案选择 -->
|
||||
<view class="card" wx:if="{{!isDemo}}">
|
||||
<view class="section-title">我的生辰</view>
|
||||
<view class="flex gap-2 profile-chips">
|
||||
<view
|
||||
class="profile-chip {{item.id === activeProfileId ? 'active' : ''}}"
|
||||
wx:for="{{profiles}}"
|
||||
wx:key="id"
|
||||
bindtap="selectProfile"
|
||||
data-id="{{item.id}}"
|
||||
>{{item.name}}</view>
|
||||
<view class="profile-chip add-chip" bindtap="goSettings">管理 ›</view>
|
||||
</view>
|
||||
<view class="text-xs text-muted mt-1">
|
||||
{{profile.name}} · {{profile.gender === 'female' ? '女' : '男'}} · 公历 {{profile.birthday}} {{profile.birthTime}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 四柱八字 -->
|
||||
<view 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>
|
||||
|
||||
+200
-23
@@ -9,35 +9,103 @@
|
||||
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;
|
||||
color: #6B5E53;
|
||||
margin-bottom: 8rpx;
|
||||
display: block;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
padding: 20rpx;
|
||||
background: #FFFBF5;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #E8D5C4;
|
||||
/* 示例横幅 */
|
||||
.demo-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
background: #FFF7E6;
|
||||
border: 1rpx solid #F0D9A8;
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.btn-outline.active {
|
||||
background: #C41E3A;
|
||||
.demo-tag {
|
||||
flex-shrink: 0;
|
||||
background: #D99A06;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
padding: 16rpx 8rpx;
|
||||
background: #FFFBF5;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
@@ -47,18 +115,69 @@
|
||||
color: #9E8E7E;
|
||||
}
|
||||
|
||||
.pillar-ganzhi {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #C41E3A;
|
||||
margin: 8rpx 0;
|
||||
}
|
||||
|
||||
.pillar-ten {
|
||||
font-size: 20rpx;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -79,23 +198,29 @@
|
||||
.element-track {
|
||||
flex: 1;
|
||||
height: 16rpx;
|
||||
background: #E8D5C4;
|
||||
background: #F0E6DA;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.element-fill {
|
||||
height: 100%;
|
||||
background: #C41E3A;
|
||||
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 {
|
||||
width: 60rpx;
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
/* 大运/流年 */
|
||||
.fortune-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -105,3 +230,55 @@
|
||||
border-radius: 12rpx;
|
||||
min-width: 160rpx;
|
||||
}
|
||||
|
||||
/* 字段说明 */
|
||||
.glossary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.glossary-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.glossary-toggle {
|
||||
font-size: 24rpx;
|
||||
color: #9E8E7E;
|
||||
}
|
||||
|
||||
.glossary-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #F0E6DA;
|
||||
}
|
||||
|
||||
.glossary-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.glossary-term {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #C41E3A;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.glossary-desc {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #6B5E53;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bottom-actions {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
text-align: center;
|
||||
font-size: 20rpx;
|
||||
color: #9E8E7E;
|
||||
padding: 8rpx 0 24rpx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const birthProfile = require('../../utils/birth-profile.js');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loggedIn: false,
|
||||
profiles: [],
|
||||
activeProfileId: '',
|
||||
maxFree: birthProfile.MAX_PROFILES,
|
||||
canAdd: true,
|
||||
showAddForm: false,
|
||||
formName: '',
|
||||
formGender: 'male',
|
||||
formDate: '1990-01-01',
|
||||
formTime: '12:00'
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ loggedIn: birthProfile.isLoggedIn() });
|
||||
this.loadProfiles();
|
||||
},
|
||||
|
||||
loadProfiles() {
|
||||
const render = () => {
|
||||
const profiles = birthProfile.getProfiles();
|
||||
const active = birthProfile.getActiveProfile();
|
||||
this.setData({
|
||||
profiles,
|
||||
activeProfileId: active ? active.id : '',
|
||||
canAdd: profiles.length < this.data.maxFree
|
||||
});
|
||||
};
|
||||
// 已登录时先从服务端同步,失败静默回退本地
|
||||
if (birthProfile.isLoggedIn()) {
|
||||
birthProfile.syncFromServer().then(render).catch(render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAddForm() {
|
||||
if (!this.data.showAddForm && !this.data.canAdd) {
|
||||
wx.showToast({ title: `最多可免费绑定 ${this.data.maxFree} 个生辰`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
this.setData({ showAddForm: !this.data.showAddForm });
|
||||
},
|
||||
|
||||
onFormName(e) {
|
||||
this.setData({ formName: e.detail.value });
|
||||
},
|
||||
|
||||
setFormGender(e) {
|
||||
this.setData({ formGender: e.currentTarget.dataset.g });
|
||||
},
|
||||
|
||||
onFormDate(e) {
|
||||
this.setData({ formDate: e.detail.value });
|
||||
},
|
||||
|
||||
onFormTime(e) {
|
||||
this.setData({ formTime: e.detail.value });
|
||||
},
|
||||
|
||||
submitProfile() {
|
||||
const { formName, formGender, formDate, formTime } = this.data;
|
||||
if (!formDate || !formTime) {
|
||||
wx.showToast({ title: '请选择出生日期和时间', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const result = birthProfile.addProfile({
|
||||
name: formName.trim() || '未命名',
|
||||
gender: formGender,
|
||||
birthday: formDate,
|
||||
birthTime: formTime
|
||||
});
|
||||
if (!result.ok) {
|
||||
wx.showToast({ title: result.msg, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
// 已登录时推送到服务端(静默失败,不影响本地)
|
||||
birthProfile.pushProfileToServer(result.profile);
|
||||
this.setData({ showAddForm: false, formName: '' });
|
||||
wx.showToast({ title: '绑定成功', icon: 'success' });
|
||||
this.loadProfiles();
|
||||
},
|
||||
|
||||
removeProfile(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const profile = this.data.profiles.find(p => p.id === id);
|
||||
if (!profile) return;
|
||||
wx.showModal({
|
||||
title: '删除生辰',
|
||||
content: `确定删除「${profile.name}」的生辰信息吗?`,
|
||||
success: (res) => {
|
||||
if (!res.confirm) return;
|
||||
birthProfile.removeProfile(id);
|
||||
birthProfile.removeProfileFromServer(profile);
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
this.loadProfiles();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 未登录时引导回个人中心登录
|
||||
goLogin() {
|
||||
wx.navigateBack();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "生辰管理",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<view class="container">
|
||||
<!-- 未登录提示 -->
|
||||
<view class="card login-tip" wx:if="{{!loggedIn}}">
|
||||
<text class="text-sm">当前未登录,生辰仅保存在本机</text>
|
||||
<text class="text-xs text-muted block mt-1">登录后可云端同步,换设备不丢失</text>
|
||||
<view class="btn-outline text-center mt-2" bindtap="goLogin">去登录</view>
|
||||
</view>
|
||||
|
||||
<!-- 生辰管理 -->
|
||||
<view class="card">
|
||||
<view class="section-title flex justify-between items-center">
|
||||
<text>生辰管理</text>
|
||||
<text class="text-xs text-muted">{{profiles.length}}/{{maxFree}}</text>
|
||||
</view>
|
||||
<view class="text-xs text-muted mb-2">八字排盘基于生辰信息,每位用户可免费绑定 {{maxFree}} 个出生年月(如本人、家人)。</view>
|
||||
|
||||
<view class="profile-list" wx:if="{{profiles.length > 0}}">
|
||||
<view class="profile-item" wx:for="{{profiles}}" wx:key="id">
|
||||
<view class="flex justify-between items-center">
|
||||
<view>
|
||||
<view class="flex items-center gap-1">
|
||||
<text class="text-base font-medium">{{item.name}}</text>
|
||||
<text class="badge badge-lucky" wx:if="{{item.id === activeProfileId}}">使用中</text>
|
||||
</view>
|
||||
<text class="text-xs text-muted block">{{item.gender === 'female' ? '女' : '男'}} · {{item.birthday}} {{item.birthTime}}</text>
|
||||
</view>
|
||||
<view class="btn-ghost text-unlucky" bindtap="removeProfile" data-id="{{item.id}}">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="text-center text-muted p-2" wx:else>
|
||||
<text>暂未绑定生辰,请到下方添加</text>
|
||||
</view>
|
||||
|
||||
<view class="btn-outline text-center mt-2" bindtap="toggleAddForm" wx:if="{{canAdd}}">
|
||||
{{showAddForm ? '收起' : '+ 添加生辰'}}
|
||||
</view>
|
||||
<view class="text-center text-xs text-muted mt-2" wx:else>
|
||||
免费名额已用完,可删除旧档案后添加
|
||||
</view>
|
||||
|
||||
<!-- 添加表单 -->
|
||||
<block wx:if="{{showAddForm}}">
|
||||
<view class="form-item mt-2">
|
||||
<text class="form-label">备注名(如:本人、爸爸)</text>
|
||||
<input class="form-input" placeholder="未命名" value="{{formName}}" bindinput="onFormName"/>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">性别</text>
|
||||
<view class="flex gap-2">
|
||||
<view class="btn-outline {{formGender === 'male' ? 'active' : ''}}" bindtap="setFormGender" data-g="male">男</view>
|
||||
<view class="btn-outline {{formGender === 'female' ? 'active' : ''}}" bindtap="setFormGender" data-g="female">女</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">出生日期(公历)</text>
|
||||
<picker mode="date" value="{{formDate}}" start="1900-01-01" end="2100-12-31" bindchange="onFormDate">
|
||||
<view class="picker-input">{{formDate}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">出生时间</text>
|
||||
<picker mode="time" value="{{formTime}}" bindchange="onFormTime">
|
||||
<view class="picker-input">{{formTime}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="btn-primary text-center" bindtap="submitProfile">保存生辰</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,56 @@
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
color: #C41E3A;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
border: 1rpx dashed #C41E3A;
|
||||
}
|
||||
|
||||
/* 生辰档案 */
|
||||
.profile-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.profile-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 添加生辰表单 */
|
||||
.form-item {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 24rpx;
|
||||
color: #6B5E53;
|
||||
margin-bottom: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 20rpx;
|
||||
background: #FFFBF5;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
padding: 20rpx;
|
||||
background: #FFFBF5;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.btn-outline.active {
|
||||
background: #C41E3A;
|
||||
color: #FFFFFF;
|
||||
border-color: #C41E3A;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
Page({
|
||||
data: {
|
||||
bookmarks: []
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadBookmarks();
|
||||
},
|
||||
|
||||
loadBookmarks() {
|
||||
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
||||
this.setData({ bookmarks });
|
||||
},
|
||||
|
||||
removeBookmark(e) {
|
||||
const date = e.currentTarget.dataset.date;
|
||||
const bookmarks = this.data.bookmarks.filter(b => b.date !== date);
|
||||
wx.setStorageSync('lunar-bookmarks', bookmarks);
|
||||
this.setData({ bookmarks });
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
},
|
||||
|
||||
// 点击收藏项跳转到当日详情
|
||||
openDetail(e) {
|
||||
const date = e.currentTarget.dataset.date;
|
||||
wx.navigateTo({ url: `/pages/day-detail/day-detail?date=${date}` });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的收藏",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<view class="container">
|
||||
<view class="card">
|
||||
<view class="section-title">我的收藏</view>
|
||||
<view class="bookmark-list" wx:if="{{bookmarks.length > 0}}">
|
||||
<view class="bookmark-item" wx:for="{{bookmarks}}" wx:key="date" bindtap="openDetail" data-date="{{item.date}}">
|
||||
<view class="flex justify-between items-center">
|
||||
<view>
|
||||
<text class="text-base font-medium">{{item.date}}</text>
|
||||
<text class="text-xs text-muted block">{{item.lunarDate}}</text>
|
||||
</view>
|
||||
<view class="btn-ghost text-unlucky" catchtap="removeBookmark" data-date="{{item.date}}">删除</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-tip" wx:else>
|
||||
<text class="text-sm text-muted">暂无收藏</text>
|
||||
<text class="text-xs text-muted block mt-1">在日期详情页点击「收藏」即可添加</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,24 @@
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
color: #C41E3A;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.bookmark-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="custom-nav" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-inner" style="height: {{navBarHeight}}px;">
|
||||
<view class="nav-toggle" bindtap="toggleView">
|
||||
<text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text>
|
||||
</view>
|
||||
<text class="nav-title font-chinese">老黄历</text>
|
||||
<view class="nav-right">
|
||||
<view class="nav-left">
|
||||
<view class="nav-toggle" bindtap="toggleView">
|
||||
<text class="nav-toggle-text">{{viewMode === 'day' ? '月历' : '日历'}}</text>
|
||||
</view>
|
||||
<text class="nav-zodiac">{{dayInfo.zodiac}}年</text>
|
||||
</view>
|
||||
<text class="nav-title font-chinese">老黄历</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -64,8 +65,8 @@
|
||||
星期{{dayItem.dayInfo.weekDay}} · {{dayItem.legalHoliday ? dayItem.legalHoliday + ' · 放假' : (dayItem.workday ? '工作日 · 上班' : '周末 · 休息')}}
|
||||
</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-title yi">宜</view>
|
||||
<view class="frame-yiji-items">
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
@@ -36,6 +43,13 @@
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4rpx;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nav-placeholder {
|
||||
min-width: 96rpx;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@@ -46,8 +60,9 @@
|
||||
|
||||
.nav-zodiac {
|
||||
color: #FFFFFF;
|
||||
font-size: 24rpx;
|
||||
font-size: 26rpx;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
Page({
|
||||
data: {
|
||||
weekStartDay: 0,
|
||||
ziHourSect: 'lateZiNextDay',
|
||||
solarTime: true,
|
||||
showBuddhist: false,
|
||||
highlightLunar: true
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadSettings();
|
||||
},
|
||||
|
||||
loadSettings() {
|
||||
const settings = wx.getStorageSync('lunar-settings') || {};
|
||||
this.setData({
|
||||
weekStartDay: settings.weekStartDay || 0,
|
||||
ziHourSect: settings.ziHourSect || 'lateZiNextDay',
|
||||
solarTime: settings.solarTime !== false,
|
||||
showBuddhist: settings.showBuddhist === true,
|
||||
highlightLunar: settings.highlightLunar !== false
|
||||
});
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
const { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar } = this.data;
|
||||
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar });
|
||||
},
|
||||
|
||||
setWeekStart(e) {
|
||||
this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
setZiSect(e) {
|
||||
this.setData({ ziHourSect: e.currentTarget.dataset.v });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onSolarTimeChange(e) {
|
||||
this.setData({ solarTime: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onBuddhistChange(e) {
|
||||
this.setData({ showBuddhist: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onHighlightLunarChange(e) {
|
||||
this.setData({ highlightLunar: e.detail.value });
|
||||
this.saveSettings();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "偏好设置",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<view class="container">
|
||||
<view class="card">
|
||||
<view class="section-title">偏好设置</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">周起始日</text>
|
||||
<view class="flex gap-2">
|
||||
<view class="btn-outline {{weekStartDay === 0 ? 'active' : ''}}" bindtap="setWeekStart" data-v="0">周日</view>
|
||||
<view class="btn-outline {{weekStartDay === 1 ? 'active' : ''}}" bindtap="setWeekStart" data-v="1">周一</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">子时流派</text>
|
||||
<view class="flex gap-2">
|
||||
<view class="btn-outline {{ziHourSect === 'lateZiNextDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiNextDay">晚子时算次日</view>
|
||||
<view class="btn-outline {{ziHourSect === 'lateZiSameDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiSameDay">晚子时算当日</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">真太阳时校正</text>
|
||||
<switch checked="{{solarTime}}" bindchange="onSolarTimeChange" color="#C41E3A"/>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">显示佛历提醒</text>
|
||||
<switch checked="{{showBuddhist}}" bindchange="onBuddhistChange" color="#C41E3A"/>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">初一十五高亮</text>
|
||||
<switch checked="{{highlightLunar}}" bindchange="onHighlightLunarChange" color="#C41E3A"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="text-xs text-muted text-center mt-2">设置仅保存在本机,修改后立即生效</view>
|
||||
</view>
|
||||
@@ -0,0 +1,32 @@
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
color: #C41E3A;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.btn-outline.active {
|
||||
background: #C41E3A;
|
||||
color: #FFFFFF;
|
||||
border-color: #C41E3A;
|
||||
}
|
||||
@@ -1,98 +1,104 @@
|
||||
const versionInfo = require('../../utils/version.js');
|
||||
const { request } = require('../../utils/request.js');
|
||||
const birthProfile = require('../../utils/birth-profile.js');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
userInfo: null,
|
||||
weekStartDay: 0,
|
||||
ziHourSect: 'lateZiNextDay',
|
||||
solarTime: true,
|
||||
showBuddhist: false,
|
||||
highlightLunar: true,
|
||||
bookmarks: [],
|
||||
loggedIn: false,
|
||||
logging: false,
|
||||
// 菜单角标
|
||||
profileCount: 0,
|
||||
maxFree: birthProfile.MAX_PROFILES,
|
||||
bookmarkCount: 0,
|
||||
version: versionInfo.version,
|
||||
buildNumber: versionInfo.buildNumber,
|
||||
buildTime: versionInfo.buildTime,
|
||||
commitSha: versionInfo.commitSha
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadSettings();
|
||||
this.loadBookmarks();
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadBookmarks();
|
||||
this.refreshUser();
|
||||
this.refreshCounts();
|
||||
},
|
||||
|
||||
loadSettings() {
|
||||
const settings = wx.getStorageSync('lunar-settings') || {};
|
||||
refreshUser() {
|
||||
const token = wx.getStorageSync('token');
|
||||
const userInfo = wx.getStorageSync('lunar-user-info');
|
||||
this.setData({ loggedIn: !!token, userInfo: userInfo || null });
|
||||
},
|
||||
|
||||
refreshCounts() {
|
||||
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
||||
const profiles = birthProfile.getProfiles();
|
||||
this.setData({
|
||||
weekStartDay: settings.weekStartDay || 0,
|
||||
ziHourSect: settings.ziHourSect || 'lateZiNextDay',
|
||||
solarTime: settings.solarTime !== false,
|
||||
showBuddhist: settings.showBuddhist === true,
|
||||
highlightLunar: settings.highlightLunar !== false
|
||||
bookmarkCount: bookmarks.length,
|
||||
profileCount: profiles.length
|
||||
});
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
const { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar } = this.data;
|
||||
wx.setStorageSync('lunar-settings', { weekStartDay, ziHourSect, solarTime, showBuddhist, highlightLunar });
|
||||
// ==================== 登录 ====================
|
||||
|
||||
login() {
|
||||
if (this.data.logging) return;
|
||||
this.setData({ logging: true });
|
||||
wx.login({
|
||||
success: (loginRes) => {
|
||||
if (!loginRes.code) {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: '微信登录失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
request({
|
||||
url: '/api/user/login',
|
||||
method: 'POST',
|
||||
data: { code: loginRes.code }
|
||||
}).then((res) => {
|
||||
if (res && res.code === 0 && res.data && res.data.token) {
|
||||
wx.setStorageSync('token', res.data.token);
|
||||
wx.setStorageSync('lunar-user-info', res.data.user);
|
||||
this.setData({ userInfo: res.data.user, loggedIn: true, logging: false });
|
||||
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||
// 登录后拉取云端生辰档案,保证菜单计数准确
|
||||
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
|
||||
} else {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: (res && res.msg) || '登录失败', icon: 'none' });
|
||||
}
|
||||
}).catch(() => {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: '登录失败,请稍后重试', icon: 'none' });
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
this.setData({ logging: false });
|
||||
wx.showToast({ title: '微信登录失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadBookmarks() {
|
||||
const bookmarks = wx.getStorageSync('lunar-bookmarks') || [];
|
||||
this.setData({ bookmarks });
|
||||
},
|
||||
|
||||
setWeekStart(e) {
|
||||
this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
setZiSect(e) {
|
||||
this.setData({ ziHourSect: e.currentTarget.dataset.v });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onSolarTimeChange(e) {
|
||||
this.setData({ solarTime: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onBuddhistChange(e) {
|
||||
this.setData({ showBuddhist: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
onHighlightLunarChange(e) {
|
||||
this.setData({ highlightLunar: e.detail.value });
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
removeBookmark(e) {
|
||||
const date = e.currentTarget.dataset.date;
|
||||
let bookmarks = this.data.bookmarks.filter(b => b.date !== date);
|
||||
wx.setStorageSync('lunar-bookmarks', bookmarks);
|
||||
this.setData({ bookmarks });
|
||||
wx.showToast({ title: '已删除', icon: 'success' });
|
||||
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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
navTo(e) {
|
||||
wx.navigateTo({ url: e.currentTarget.dataset.url });
|
||||
},
|
||||
|
||||
login() {
|
||||
wx.getUserProfile({
|
||||
desc: '用于完善用户资料',
|
||||
success: (res) => {
|
||||
this.setData({ userInfo: res.userInfo });
|
||||
wx.setStorageSync('lunar-user-info', res.userInfo);
|
||||
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '登录失败', icon: 'none' });
|
||||
}
|
||||
});
|
||||
const url = e.currentTarget.dataset.url;
|
||||
// tabBar 页面必须用 switchTab 打开
|
||||
const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings'];
|
||||
if (tabPages.includes(url)) {
|
||||
wx.switchTab({ url });
|
||||
} else {
|
||||
wx.navigateTo({ url });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,73 +1,58 @@
|
||||
<view class="container">
|
||||
<!-- 用户信息 -->
|
||||
<!-- 登录与账号 -->
|
||||
<view class="card">
|
||||
<view class="section-title">用户信息</view>
|
||||
<view class="flex items-center gap-2" wx:if="{{userInfo}}">
|
||||
<image src="{{userInfo.avatarUrl}}" class="avatar" mode="aspectFill"/>
|
||||
<view>
|
||||
<text class="text-base font-medium">{{userInfo.nickName}}</text>
|
||||
<text class="text-xs text-muted block">已登录</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-primary" wx:else bindtap="login">微信登录</view>
|
||||
</view>
|
||||
|
||||
<!-- 偏好设置 -->
|
||||
<view class="card">
|
||||
<view class="section-title">偏好设置</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">周起始日</text>
|
||||
<view class="flex gap-2">
|
||||
<view class="btn-outline {{weekStartDay === 0 ? 'active' : ''}}" bindtap="setWeekStart" data-v="0">周日</view>
|
||||
<view class="btn-outline {{weekStartDay === 1 ? 'active' : ''}}" bindtap="setWeekStart" data-v="1">周一</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">子时流派</text>
|
||||
<view class="flex gap-2">
|
||||
<view class="btn-outline {{ziHourSect === 'lateZiNextDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiNextDay">晚子时算次日</view>
|
||||
<view class="btn-outline {{ziHourSect === 'lateZiSameDay' ? 'active' : ''}}" bindtap="setZiSect" data-v="lateZiSameDay">晚子时算当日</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">真太阳时校正</text>
|
||||
<switch checked="{{solarTime}}" bindchange="onSolarTimeChange" color="#C41E3A"/>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">显示佛历提醒</text>
|
||||
<switch checked="{{showBuddhist}}" bindchange="onBuddhistChange" color="#C41E3A"/>
|
||||
</view>
|
||||
|
||||
<view class="setting-item">
|
||||
<text class="setting-label">初一十五高亮</text>
|
||||
<switch checked="{{highlightLunar}}" bindchange="onHighlightLunarChange" color="#C41E3A"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏管理 -->
|
||||
<view class="card">
|
||||
<view class="section-title">我的收藏</view>
|
||||
<view class="bookmark-list" wx:if="{{bookmarks.length > 0}}">
|
||||
<view class="bookmark-item" wx:for="{{bookmarks}}" wx:key="date">
|
||||
<view class="flex justify-between items-center">
|
||||
<view>
|
||||
<text class="text-base font-medium">{{item.date}}</text>
|
||||
<text class="text-xs text-muted block">{{item.lunarDate}}</text>
|
||||
</view>
|
||||
<view class="btn-ghost text-unlucky" bindtap="removeBookmark" data-date="{{item.date}}">删除</view>
|
||||
<view class="flex items-center justify-between" wx:if="{{loggedIn}}">
|
||||
<view class="flex items-center gap-2">
|
||||
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
|
||||
<view class="avatar avatar-placeholder" wx:else>
|
||||
<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>
|
||||
<view class="text-center text-muted" wx:else>
|
||||
<text>暂无收藏</text>
|
||||
<block wx:else>
|
||||
<view class="flex items-center gap-2 mb-2">
|
||||
<view class="avatar avatar-placeholder">
|
||||
<text>客</text>
|
||||
</view>
|
||||
<view>
|
||||
<text class="text-base font-medium">未登录</text>
|
||||
<text class="text-xs text-muted block">绑定的生辰仅保存在本机,登录后才生效</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-primary text-center" bindtap="login">{{logging ? '登录中…' : '微信登录'}}</view>
|
||||
<view class="text-xs text-muted text-center mt-2">登录后可将生辰档案同步到云端,换设备不丢失</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 我的数据 -->
|
||||
<view class="card">
|
||||
<view class="section-title">我的数据</view>
|
||||
<view class="menu-item" bindtap="navTo" data-url="/pages/birth-profiles/birth-profiles">
|
||||
<text class="menu-label">生辰管理</text>
|
||||
<view class="menu-right">
|
||||
<text class="menu-count">{{profileCount}}/{{maxFree}}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="navTo" data-url="/pages/bookmarks/bookmarks">
|
||||
<text class="menu-label">我的收藏</text>
|
||||
<view class="menu-right">
|
||||
<text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="navTo" data-url="/pages/preferences/preferences">
|
||||
<text class="menu-label">设置</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口 -->
|
||||
<!-- 功能 -->
|
||||
<view class="card">
|
||||
<view class="section-title">功能</view>
|
||||
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune">
|
||||
|
||||
@@ -10,40 +10,18 @@
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.btn-outline.active {
|
||||
justify-content: center;
|
||||
background: #C41E3A;
|
||||
color: #FFFFFF;
|
||||
border-color: #C41E3A;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #E8D5C4;
|
||||
}
|
||||
|
||||
.bookmark-item:last-child {
|
||||
border-bottom: none;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
@@ -62,6 +40,17 @@
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.menu-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.menu-count {
|
||||
font-size: 24rpx;
|
||||
color: #9E8E7E;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
color: #C9B8A6;
|
||||
font-size: 36rpx;
|
||||
|
||||
+389
-175
@@ -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 = [
|
||||
{ x: 16, y: 34 },
|
||||
{ x: 27, y: 21 },
|
||||
{ x: 39, y: 14 },
|
||||
{ x: 52, y: 12 },
|
||||
{ x: 63, y: 18 },
|
||||
{ x: 74, y: 26 },
|
||||
{ x: 84, y: 38 },
|
||||
{ x: 12, 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 },
|
||||
{ x: 30, y: 30 }, { x: 36, y: 20 }, { x: 43, y: 14 }, { x: 50, y: 12 },
|
||||
{ x: 57, y: 15 }, { x: 64, y: 22 }, { x: 70, y: 32 }, { x: 27, y: 42 },
|
||||
{ x: 34, y: 38 }, { x: 42, y: 34 }, { x: 58, y: 34 }, { x: 66, y: 40 },
|
||||
{ x: 73, y: 48 }, { x: 27, y: 55 }, { x: 47, y: 26 }, { x: 53, y: 40 },
|
||||
{ x: 39, y: 48 }, { x: 61, y: 50 }, { x: 45, y: 56 }, { x: 31, y: 18 },
|
||||
{ x: 60, y: 26 }, { x: 68, y: 44 }, { x: 40, y: 24 }, { x: 55, y: 20 },
|
||||
{ x: 48, y: 44 }, { x: 35, y: 52 }, { x: 63, y: 56 }, { x: 29, y: 34 },
|
||||
{ x: 71, y: 40 }, { x: 52, y: 30 }, { x: 44, y: 50 },
|
||||
];
|
||||
|
||||
// 标签颜色配置
|
||||
const TAG_COLORS = {
|
||||
amber: {
|
||||
// 便签配色
|
||||
const TAG_COLORS = [
|
||||
{
|
||||
color: '#ffd700',
|
||||
bgColor: 'rgba(255, 215, 0, 0.9)',
|
||||
borderColor: 'rgba(255, 215, 0, 0.6)',
|
||||
shadowColor: 'rgba(255, 215, 0, 0.4)',
|
||||
bgColor: 'rgba(255, 224, 130, 0.92)',
|
||||
borderColor: 'rgba(255, 215, 0, 0.5)',
|
||||
shadowColor: 'rgba(255, 215, 0, 0.35)',
|
||||
},
|
||||
red: {
|
||||
{
|
||||
color: '#ff6b6b',
|
||||
bgColor: 'rgba(255, 107, 107, 0.9)',
|
||||
borderColor: 'rgba(255, 107, 107, 0.6)',
|
||||
shadowColor: 'rgba(255, 107, 107, 0.4)',
|
||||
bgColor: 'rgba(255, 150, 150, 0.92)',
|
||||
borderColor: 'rgba(255, 107, 107, 0.5)',
|
||||
shadowColor: 'rgba(255, 107, 107, 0.35)',
|
||||
},
|
||||
jade: {
|
||||
{
|
||||
color: '#51cf66',
|
||||
bgColor: 'rgba(81, 207, 102, 0.9)',
|
||||
borderColor: 'rgba(81, 207, 102, 0.6)',
|
||||
shadowColor: 'rgba(81, 207, 102, 0.4)',
|
||||
bgColor: 'rgba(140, 226, 155, 0.92)',
|
||||
borderColor: 'rgba(81, 207, 102, 0.5)',
|
||||
shadowColor: 'rgba(81, 207, 102, 0.35)',
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
// 生成伪随机数(用于萤火虫和花瓣位置)
|
||||
// 挂树数量上限,超出的以弹幕展示
|
||||
const MAX_HANG = 30;
|
||||
|
||||
// 伪随机(保证每次渲染一致)
|
||||
function seeded(i, salt) {
|
||||
const v = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
||||
return v - Math.floor(v);
|
||||
@@ -48,9 +46,12 @@ function seeded(i, salt) {
|
||||
|
||||
Page({
|
||||
data: {
|
||||
wishes: [],
|
||||
recentWishes: [],
|
||||
loading: true,
|
||||
trees: [],
|
||||
currentTreeIndex: 0,
|
||||
currentDanmaku: [],
|
||||
topWishes: [],
|
||||
detailWish: null,
|
||||
fireflies: [],
|
||||
showCreateModal: false,
|
||||
wishContent: '',
|
||||
wishType: 'free',
|
||||
@@ -58,25 +59,51 @@ Page({
|
||||
maxPaidLength: 100,
|
||||
products: [],
|
||||
selectedProduct: null,
|
||||
fireflies: [],
|
||||
petals: [],
|
||||
},
|
||||
|
||||
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.loadWishes();
|
||||
this.loadTrees();
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadWishes().then(() => {
|
||||
wx.stopPullDownRefresh();
|
||||
});
|
||||
onUnload() {
|
||||
this.stopDanmakuScheduler();
|
||||
},
|
||||
|
||||
// 初始化氛围效果
|
||||
onHide() {
|
||||
this.stopDanmakuScheduler();
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 从后台/下一页返回时,若已有弹幕数据则恢复调度
|
||||
if (this.danmakuRows && this.danmakuRows.length && !(this.danmakuTimers && this.danmakuTimers.length)) {
|
||||
this.startDanmakuScheduler(this.danmakuRows);
|
||||
}
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
const { currentTreeIndex, trees } = this.data;
|
||||
const tree = trees[currentTreeIndex];
|
||||
if (tree) {
|
||||
this.loadTreeWishes(currentTreeIndex, true).then(() => {
|
||||
wx.stopPullDownRefresh();
|
||||
});
|
||||
} else {
|
||||
wx.stopPullDownRefresh();
|
||||
}
|
||||
},
|
||||
|
||||
// 初始化萤火虫氛围
|
||||
initAmbiance() {
|
||||
// 萤火虫
|
||||
const fireflies = [];
|
||||
for (let i = 0; i < 22; i++) {
|
||||
fireflies.push({
|
||||
@@ -87,137 +114,325 @@ Page({
|
||||
delay: seeded(i, 5) * 4,
|
||||
});
|
||||
}
|
||||
|
||||
// 花瓣
|
||||
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 });
|
||||
this.setData({ fireflies });
|
||||
},
|
||||
|
||||
// 加载许愿
|
||||
async loadWishes() {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/wish/tree/1/wishes',
|
||||
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;
|
||||
|
||||
// 图片坐标(正方形图)→ 视口坐标
|
||||
// aspectFill 下竖屏会左右对称裁剪,横向需要按比例展开
|
||||
transformSlot(slot) {
|
||||
const r = this.vh / this.vw;
|
||||
const x = slot.x * r - (r - 1) * 50;
|
||||
return {
|
||||
x: Math.min(92, Math.max(8, base.x + jitterX)),
|
||||
y: Math.min(58, Math.max(10, base.y + jitterY)),
|
||||
x: Math.min(96, Math.max(4, x)),
|
||||
y: slot.y,
|
||||
};
|
||||
},
|
||||
|
||||
// 模拟许愿数据
|
||||
// 加载许愿树列表(后端提供几个就显示几个分屏)
|
||||
async loadTrees() {
|
||||
try {
|
||||
const res = await request({ url: '/api/wish/trees', method: 'GET' });
|
||||
if (res.code === 0 && res.data.list && res.data.list.length > 0) {
|
||||
const trees = res.data.list.map((t) => ({
|
||||
...t,
|
||||
imageUrl: this.resolveImageUrl(t.image),
|
||||
hangWishes: [],
|
||||
}));
|
||||
this.setData({ trees });
|
||||
this.loadTreeWishes(0);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载许愿树失败:', err);
|
||||
}
|
||||
|
||||
// 兜底:单棵本地树
|
||||
this.setData({
|
||||
trees: [{
|
||||
id: 1,
|
||||
name: '祈福树',
|
||||
imageUrl: '/images/wish-tree.jpg',
|
||||
hangWishes: [],
|
||||
}],
|
||||
});
|
||||
this.loadTreeWishes(0);
|
||||
},
|
||||
|
||||
// 解析树背景图地址(相对路径拼接服务器域名)
|
||||
resolveImageUrl(image) {
|
||||
if (!image) return '/images/wish-tree.jpg';
|
||||
if (/^https?:\/\//.test(image)) return image;
|
||||
return BASE_URL + image;
|
||||
},
|
||||
|
||||
// 分屏切换
|
||||
onSwiperChange(e) {
|
||||
const index = e.detail.current;
|
||||
this.setData({ currentTreeIndex: index });
|
||||
|
||||
const tree = this.data.trees[index];
|
||||
if (!tree) return;
|
||||
|
||||
const cache = this.wishCache[tree.id];
|
||||
if (cache) {
|
||||
// 已加载过,直接展示缓存
|
||||
this.applyTreeWishes(index, cache);
|
||||
} else {
|
||||
this.loadTreeWishes(index);
|
||||
}
|
||||
},
|
||||
|
||||
// 加载指定树的许愿(默认100条)
|
||||
async loadTreeWishes(index, force) {
|
||||
const trees = this.data.trees;
|
||||
const tree = trees[index];
|
||||
if (!tree) return;
|
||||
if (!force && this.wishCache[tree.id]) {
|
||||
this.applyTreeWishes(index, this.wishCache[tree.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
let list = [];
|
||||
try {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// 其余以弹幕飘过:树桩处3行 + 置顶轮播下方1行,慢速均匀间隔不重叠
|
||||
const danmaku = this.buildDanmaku(list.slice(MAX_HANG));
|
||||
|
||||
const cache = { hangWishes, danmaku, top };
|
||||
this.wishCache[tree.id] = cache;
|
||||
this.applyTreeWishes(index, cache);
|
||||
},
|
||||
|
||||
// 应用某棵树的许愿数据到视图
|
||||
applyTreeWishes(index, cache) {
|
||||
this.setData({
|
||||
[`trees[${index}].hangWishes`]: cache.hangWishes,
|
||||
topWishes: cache.top,
|
||||
});
|
||||
// 启动弹幕调度:每行一条完毕后再出下一条
|
||||
this.startDanmakuScheduler(cache.danmaku);
|
||||
},
|
||||
|
||||
// 启动弹幕调度:每行同一时刻只播一条,前一条飘完再出下一条(循环)
|
||||
startDanmakuScheduler(rows) {
|
||||
this.stopDanmakuScheduler();
|
||||
this.danmakuRows = rows || [];
|
||||
this.danmakuTimers = [];
|
||||
|
||||
if (!this.danmakuRows.length) {
|
||||
this.setData({ currentDanmaku: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const GAP = 1.5; // 同一行前一条飘完后,间隔再出下一条(秒)
|
||||
|
||||
// 每行独立调度:row 行的第 idx 条播完后,隔 GAP 秒播第 idx+1 条
|
||||
const playRow = (row, idx) => {
|
||||
const rowData = this.danmakuRows[row];
|
||||
if (!rowData) return;
|
||||
const item = rowData.list[idx % rowData.list.length];
|
||||
// 更新该行当前弹幕(用行号作为 key 保证 setData 局部刷新)
|
||||
this.setData({
|
||||
[`currentDanmaku[${row}]`]: {
|
||||
key: `${row}-${idx % rowData.list.length}-${Date.now()}`,
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
author: item.author,
|
||||
type: item.type,
|
||||
top: rowData.top,
|
||||
duration: rowData.duration,
|
||||
},
|
||||
});
|
||||
// 前一条飘完 + 间隔后,播下一条
|
||||
const timer = setTimeout(() => {
|
||||
playRow(row, idx + 1);
|
||||
}, (rowData.duration + GAP) * 1000);
|
||||
this.danmakuTimers[row] = timer;
|
||||
};
|
||||
|
||||
// 初始化 currentDanmaku 数组长度,并启动每行
|
||||
this.setData({ currentDanmaku: new Array(this.danmakuRows.length).fill(null) });
|
||||
this.danmakuRows.forEach((_, row) => {
|
||||
// 各行错开启动,避免 4 行同时从右侧涌出
|
||||
const startTimer = setTimeout(() => playRow(row, 0), row * 1200);
|
||||
this.danmakuTimers.push(startTimer);
|
||||
});
|
||||
},
|
||||
|
||||
// 停止弹幕调度
|
||||
stopDanmakuScheduler() {
|
||||
if (this.danmakuTimers) {
|
||||
this.danmakuTimers.forEach((t) => clearTimeout(t));
|
||||
this.danmakuTimers = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 构建弹幕:共 4 行(行 0-2 树桩处三行,行 3 顶部置顶轮播下方)
|
||||
// 按行分组返回,由 JS 定时器控制:同一行一条完毕后再出下一条,不重叠
|
||||
buildDanmaku(list) {
|
||||
const DURATION_BOTTOM = 30; // 树桩处一条飘完全屏的秒数(慢速)
|
||||
const DURATION_TOP = 24; // 顶部行稍快一点
|
||||
// 行位置(屏幕百分比):
|
||||
// - 行3 顶部行紧贴置顶轮播下方(稍有空隙)
|
||||
// - 行0-2 树桩处三行,第一条68%,三行紧凑(间隔5%)
|
||||
const ROW_TOPS = [68, 73, 78, 12];
|
||||
const ROW_DURATIONS = [DURATION_BOTTOM, DURATION_BOTTOM, DURATION_BOTTOM, DURATION_TOP];
|
||||
|
||||
// 轮流分配到 4 行
|
||||
const rows = [[], [], [], []];
|
||||
list.forEach((w, i) => {
|
||||
rows[i % 4].push(w);
|
||||
});
|
||||
|
||||
// 每行存该行的所有弹幕(带位置/时长),供定时器轮流播放
|
||||
return rows.map((rowList, row) => ({
|
||||
top: ROW_TOPS[row],
|
||||
duration: ROW_DURATIONS[row],
|
||||
list: rowList.map((w) => ({
|
||||
id: w.id,
|
||||
content: w.content,
|
||||
author: w.author || '匿名',
|
||||
type: w.type,
|
||||
})),
|
||||
})).filter((r) => r.list.length > 0);
|
||||
},
|
||||
|
||||
// 格式化许愿
|
||||
formatWish(w, i) {
|
||||
const colors = TAG_COLORS[i % TAG_COLORS.length];
|
||||
return {
|
||||
id: w.id,
|
||||
content: w.content,
|
||||
author: w.author || '匿名',
|
||||
type: w.type,
|
||||
isTop: !!w.isTop,
|
||||
dateStr: this.formatDate(w.createdAt),
|
||||
...colors,
|
||||
};
|
||||
},
|
||||
|
||||
// 选择挂载点(循环复用,复用时加抖动)
|
||||
pickSlot(index) {
|
||||
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() {
|
||||
return [
|
||||
{ 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' },
|
||||
const texts = [
|
||||
'愿家人平安喜乐,身体健康',
|
||||
'希望今年考研上岸,一战成硕',
|
||||
'愿世界温柔以待每一个努力的人',
|
||||
'早日遇见那个对的人',
|
||||
'祝爸妈身体硬朗,笑口常开',
|
||||
'愿所求皆如愿,所行皆坦途',
|
||||
'希望新工作顺顺利利',
|
||||
'愿此生尽兴,赤诚善良',
|
||||
'愿代码零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() {
|
||||
try {
|
||||
const res = await request({
|
||||
url: '/api/wish/products',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const res = await request({ url: '/api/wish/products', method: 'GET' });
|
||||
if (res.code === 0) {
|
||||
this.setData({ products: res.data.list || [] });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载商品失败:', err);
|
||||
this.setData({
|
||||
products: [
|
||||
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
|
||||
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
|
||||
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
|
||||
],
|
||||
});
|
||||
}
|
||||
this.setData({
|
||||
products: [
|
||||
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
|
||||
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
|
||||
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
// 打开许愿弹窗
|
||||
// 打开/关闭许愿弹窗
|
||||
openCreateModal() {
|
||||
this.setData({ showCreateModal: true });
|
||||
},
|
||||
|
||||
// 关闭许愿弹窗
|
||||
closeCreateModal() {
|
||||
this.setData({
|
||||
this.setData({
|
||||
showCreateModal: false,
|
||||
wishContent: '',
|
||||
wishType: 'free',
|
||||
@@ -225,44 +440,47 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
// 输入许愿内容
|
||||
onInputContent(e) {
|
||||
this.setData({ wishContent: e.detail.value });
|
||||
},
|
||||
|
||||
// 选择许愿类型
|
||||
selectType(e) {
|
||||
const type = e.currentTarget.dataset.type;
|
||||
this.setData({ wishType: type });
|
||||
this.setData({ wishType: e.currentTarget.dataset.type });
|
||||
},
|
||||
|
||||
// 选择商品
|
||||
selectProduct(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const product = this.data.products.find(p => p.id === id);
|
||||
const product = this.data.products.find((p) => p.id === id);
|
||||
this.setData({ selectedProduct: product });
|
||||
},
|
||||
|
||||
// 当前树ID
|
||||
currentTreeId() {
|
||||
const { trees, currentTreeIndex } = this.data;
|
||||
const tree = trees[currentTreeIndex];
|
||||
return tree ? tree.id : 1;
|
||||
},
|
||||
|
||||
// 提交许愿
|
||||
async submitWish() {
|
||||
const { wishContent, wishType, selectedProduct } = this.data;
|
||||
|
||||
const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data;
|
||||
|
||||
if (!wishContent.trim()) {
|
||||
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const maxLength = wishType === 'free' ? this.data.maxFreeLength : this.data.maxPaidLength;
|
||||
|
||||
const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength;
|
||||
if (wishContent.length > maxLength) {
|
||||
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (wishType === 'paid' && !selectedProduct) {
|
||||
wx.showToast({ title: '请选择许愿商品', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (wishType === 'paid') {
|
||||
const orderRes = await request({
|
||||
@@ -272,10 +490,9 @@ Page({
|
||||
type: 'wish',
|
||||
productId: selectedProduct.id,
|
||||
content: wishContent,
|
||||
treeId: 1,
|
||||
treeId: this.currentTreeId(),
|
||||
},
|
||||
});
|
||||
|
||||
if (orderRes.code === 0) {
|
||||
await this.wxPay(orderRes.data);
|
||||
}
|
||||
@@ -286,14 +503,13 @@ Page({
|
||||
data: {
|
||||
content: wishContent,
|
||||
type: 'free',
|
||||
treeId: 1,
|
||||
treeId: this.currentTreeId(),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.code === 0) {
|
||||
wx.showToast({ title: '许愿成功', icon: 'success' });
|
||||
this.closeCreateModal();
|
||||
this.loadWishes();
|
||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -314,7 +530,7 @@ Page({
|
||||
success: () => {
|
||||
wx.showToast({ title: '支付成功', icon: 'success' });
|
||||
this.closeCreateModal();
|
||||
this.loadWishes();
|
||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||
resolve();
|
||||
},
|
||||
fail: (err) => {
|
||||
@@ -326,14 +542,6 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
// 查看许愿详情
|
||||
viewWishDetail(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
wx.navigateTo({
|
||||
url: `/pages/wish-detail/wish-detail?id=${id}`,
|
||||
});
|
||||
},
|
||||
|
||||
// 分享
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
@@ -341,4 +549,10 @@ Page({
|
||||
path: '/pages/wish-tree/wish-tree',
|
||||
};
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: '快来许愿树许下你的愿望吧!'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,94 +1,117 @@
|
||||
<view class="container">
|
||||
<!-- 背景氛围:萤火虫 -->
|
||||
<view class="ambiance">
|
||||
<view
|
||||
wx:for="{{fireflies}}"
|
||||
wx:key="index"
|
||||
class="firefly"
|
||||
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
|
||||
></view>
|
||||
</view>
|
||||
<view class="firefly"
|
||||
wx:for="{{fireflies}}"
|
||||
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;"
|
||||
></view>
|
||||
|
||||
<!-- 背景氛围:花瓣 -->
|
||||
<view class="petals">
|
||||
<view
|
||||
wx:for="{{petals}}"
|
||||
wx:key="index"
|
||||
class="petal"
|
||||
style="left: {{item.left}}%; width: {{item.size}}px; height: {{item.size}}px; animation-duration: {{item.duration}}s; animation-delay: {{item.delay}}s;"
|
||||
></view>
|
||||
</view>
|
||||
<!-- 多树分屏轮播 -->
|
||||
<swiper
|
||||
class="tree-swiper"
|
||||
current="{{currentTreeIndex}}"
|
||||
bindchange="onSwiperChange"
|
||||
circular="{{trees.length > 1}}"
|
||||
>
|
||||
<swiper-item wx:for="{{trees}}" wx:key="id">
|
||||
<view class="tree-page">
|
||||
<!-- 背景图撑满全屏 -->
|
||||
<image
|
||||
src="{{item.imageUrl}}"
|
||||
class="tree-bg"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- 边缘暗角,让树从中心发光 -->
|
||||
<view class="vignette"></view>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<view class="main-content">
|
||||
<!-- 头部 -->
|
||||
<view class="header">
|
||||
<text class="subtitle">MAKE A WISH</text>
|
||||
<text class="title">许愿树</text>
|
||||
<text class="description">月色微凉,灯火摇曳。写下心中所愿,挂上枝头,静待花开成真。</text>
|
||||
<text class="wish-count">已有 <text class="count">{{wishes.length}}</text> 个心愿挂满枝头</text>
|
||||
</view>
|
||||
|
||||
<!-- 许愿树 -->
|
||||
<view class="tree-container">
|
||||
<image
|
||||
src="/images/wish-tree.jpg"
|
||||
class="tree-image"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
|
||||
<!-- 许愿标签 -->
|
||||
<view
|
||||
wx:for="{{wishes}}"
|
||||
wx:key="id"
|
||||
class="wish-tag {{item.isNew ? 'new' : ''}}"
|
||||
style="left: {{item.x}}%; top: {{item.y}}%;"
|
||||
bindtap="viewWishDetail"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<view class="tag-sway" style="animation-duration: {{item.swayDuration}}s; animation-delay: {{item.swayDelay}}s;">
|
||||
<!-- 挂绳 -->
|
||||
<view class="tag-string" style="background: linear-gradient(to bottom, transparent, {{item.color}});"></view>
|
||||
<!-- 绳结 -->
|
||||
<view class="tag-knot" style="background: {{item.color}}; box-shadow: 0 0 8px {{item.color}};"></view>
|
||||
<!-- 标签 -->
|
||||
<view class="tag-content" style="background: {{item.bgColor}}; border: 1px solid {{item.borderColor}}; box-shadow: 0 4px 16px {{item.shadowColor}};">
|
||||
<text class="tag-text">{{item.content}}</text>
|
||||
<text class="tag-author">—— {{item.author}}</text>
|
||||
<!-- 树上的许愿便签(最新30条) -->
|
||||
<view
|
||||
wx:for="{{item.hangWishes}}"
|
||||
wx:for-item="wish"
|
||||
wx:key="id"
|
||||
class="wish-tag {{wish.isNew ? 'new' : ''}}"
|
||||
style="left: {{wish.x}}%; top: {{wish.y}}%;"
|
||||
catchtap="openDetail"
|
||||
data-wish="{{wish}}"
|
||||
>
|
||||
<view class="tag-sway" style="animation-duration: {{wish.swayDuration}}s; animation-delay: {{wish.swayDelay}}s;">
|
||||
<view class="tag-string" style="background: linear-gradient(to bottom, transparent, {{wish.color}});"></view>
|
||||
<view class="tag-knot" style="background: {{wish.color}}; box-shadow: 0 0 8rpx {{wish.color}};"></view>
|
||||
<view class="tag-content" style="background: {{wish.bgColor}}; border: 1rpx solid {{wish.borderColor}}; box-shadow: 0 6rpx 20rpx {{wish.shadowColor}};">
|
||||
<text class="tag-text">{{wish.content}}</text>
|
||||
<text class="tag-author">—— {{wish.author}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 最近的心愿 -->
|
||||
<view class="recent-section">
|
||||
<text class="section-title">最 近 的 心 愿</text>
|
||||
<scroll-view class="recent-list" scroll-x enhanced show-scrollbar="{{false}}">
|
||||
<view
|
||||
wx:for="{{recentWishes}}"
|
||||
wx:key="id"
|
||||
class="recent-item"
|
||||
bindtap="viewWishDetail"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<text class="recent-text">{{item.content}}</text>
|
||||
<text class="recent-author">—— {{item.author}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- 弹幕层(超出30条的心愿缓缓飘过,每行一条完毕再出下一条) -->
|
||||
<view class="danmaku-layer">
|
||||
<block wx:for="{{currentDanmaku}}" wx:key="key" wx:if="{{item}}">
|
||||
<view
|
||||
class="danmaku-item {{item.type === 'paid' ? 'paid' : ''}}"
|
||||
style="top: {{item.top}}%; animation-duration: {{item.duration}}s;"
|
||||
>{{item.content}} · {{item.author}}</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 许愿按钮 -->
|
||||
<!-- 边缘暗角 -->
|
||||
<view 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">
|
||||
<button class="btn-wish" bindtap="openCreateModal">
|
||||
<text class="icon">🙏</text>
|
||||
<text>写下心愿</text>
|
||||
<text class="btn-text">写下心愿</text>
|
||||
</button>
|
||||
</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-mask" bindtap="closeCreateModal"></view>
|
||||
@@ -133,7 +156,7 @@
|
||||
|
||||
<!-- 付费商品选择 -->
|
||||
<view class="product-section" wx:if="{{wishType === 'paid'}}">
|
||||
<view class="section-title">选择许愿商品</view>
|
||||
<view class="product-title">选择许愿商品</view>
|
||||
<view class="product-list">
|
||||
<view
|
||||
wx:for="{{products}}"
|
||||
|
||||
+275
-188
@@ -6,41 +6,40 @@
|
||||
background: #0d0d1a;
|
||||
}
|
||||
|
||||
/* 背景氛围 */
|
||||
.ambiance {
|
||||
/* 多树分屏 */
|
||||
.tree-swiper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tree-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 背景图撑满全屏 */
|
||||
.tree-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 萤火虫 */
|
||||
.firefly {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: 50%;
|
||||
background: #ffd700;
|
||||
box-shadow: 0 0 8px #ffd700, 0 0 14px #ffd700;
|
||||
animation: twinkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.petals {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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;
|
||||
width: 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;
|
||||
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 {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
z-index: 5;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -148,7 +78,7 @@
|
||||
|
||||
.tag-string {
|
||||
width: 2rpx;
|
||||
height: 40rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.tag-knot {
|
||||
@@ -160,127 +90,298 @@
|
||||
|
||||
.tag-content {
|
||||
margin-top: 8rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
min-width: 160rpx;
|
||||
max-width: 200rpx;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.wish-tag:active .tag-content {
|
||||
transform: translateY(-4rpx) scale(1.05);
|
||||
width: 160rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 19rpx;
|
||||
font-weight: 500;
|
||||
color: #2a2a3a;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tag-author {
|
||||
display: block;
|
||||
font-size: 16rpx;
|
||||
color: #4a4a5a;
|
||||
margin-top: 8rpx;
|
||||
font-size: 15rpx;
|
||||
color: rgba(42, 42, 58, 0.7);
|
||||
margin-top: 6rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 最近的心愿 */
|
||||
.recent-section {
|
||||
/* 弹幕层 */
|
||||
.danmaku-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin-top: 40rpx;
|
||||
height: 100%;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
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;
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
padding: 6rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
background: rgba(13, 13, 26, 0.35);
|
||||
text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.6);
|
||||
animation-name: danmaku;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: both;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
display: inline-flex;
|
||||
.danmaku-item.paid {
|
||||
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;
|
||||
min-width: 280rpx;
|
||||
padding: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20rpx;
|
||||
backdrop-filter: blur(8px);
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.recent-author {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 12rpx;
|
||||
.top-author {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 215, 0, 0.7);
|
||||
}
|
||||
|
||||
/* 许愿按钮 */
|
||||
/* 分屏指示点 */
|
||||
.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 {
|
||||
position: fixed;
|
||||
bottom: 60rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
bottom: calc(48rpx + env(safe-area-inset-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.btn-wish {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
white-space: nowrap;
|
||||
gap: 12rpx;
|
||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 50%, #ff6b6b 100%);
|
||||
color: #fff;
|
||||
font-size: 36rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
padding: 28rpx 80rpx;
|
||||
line-height: 1;
|
||||
padding: 24rpx 72rpx;
|
||||
border-radius: 60rpx;
|
||||
border: none;
|
||||
box-shadow: 0 10rpx 40rpx rgba(196, 30, 58, 0.5),
|
||||
0 0 60rpx rgba(255, 107, 107, 0.3);
|
||||
transition: all 0.3s;
|
||||
0 0 50rpx rgba(255, 107, 107, 0.25);
|
||||
}
|
||||
|
||||
.btn-wish::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-wish:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 5rpx 20rpx rgba(196, 30, 58, 0.4);
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -351,14 +452,12 @@
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20rpx;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.type-item.active {
|
||||
border-color: #ffd700;
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
box-shadow: 0 0 30rpx rgba(255, 215, 0, 0.2);
|
||||
}
|
||||
|
||||
.type-name {
|
||||
@@ -387,10 +486,6 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wish-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.input-count {
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
@@ -403,7 +498,7 @@
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
.product-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
@@ -423,7 +518,6 @@
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20rpx;
|
||||
transition: all 0.3s;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -464,6 +558,7 @@
|
||||
border-radius: 60rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@@ -476,7 +571,6 @@
|
||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 8rpx 30rpx rgba(196, 30, 58, 0.4);
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@@ -500,30 +594,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
90% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-40px, 120vh, 0) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tag-drop {
|
||||
0% {
|
||||
transform: translateY(-24px) rotate(-8deg);
|
||||
transform: translateY(-40rpx) rotate(-8deg);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(4px) rotate(3deg);
|
||||
transform: translateY(8rpx) rotate(3deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
@@ -531,3 +608,13 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹幕:从屏幕右侧飘到左侧。每条只播一次,由 JS 控制一行一条排队出现。 */
|
||||
@keyframes danmaku {
|
||||
0% {
|
||||
transform: translateX(100vw);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
@@ -20,8 +38,8 @@ function request(options) {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(res.data);
|
||||
} else if (res.statusCode === 401) {
|
||||
// 未授权,跳转登录
|
||||
wx.navigateTo({ url: '/pages/settings/settings' });
|
||||
// 未授权:清除失效 token,由页面引导重新登录
|
||||
wx.removeStorageSync('token');
|
||||
reject(new Error('未授权'));
|
||||
} else {
|
||||
reject(new Error(res.data.msg || '请求失败'));
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
+51
-9
@@ -1,16 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
"github.com/gouki/lunar-server/internal/handler"
|
||||
"github.com/gouki/lunar-server/internal/middleware"
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// 版本信息由 CI 通过 ldflags 注入:
|
||||
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
CommitSha = "unknown"
|
||||
)
|
||||
|
||||
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
|
||||
//
|
||||
//go:embed assets/images
|
||||
var assetsFS embed.FS
|
||||
|
||||
func main() {
|
||||
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
|
||||
_ = godotenv.Load(".env.local", "../.env.local", ".env")
|
||||
|
||||
// 将构建时注入的版本信息传递给 handler
|
||||
handler.Version = Version
|
||||
handler.BuildTime = BuildTime
|
||||
handler.CommitSha = CommitSha
|
||||
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
@@ -28,16 +54,21 @@ func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
// 创建路由(gin.Default 自带访问日志与 Recovery)
|
||||
r := gin.Default()
|
||||
|
||||
// 中间件
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Logger())
|
||||
|
||||
// 静态文件
|
||||
// 静态文件与管理后台模板
|
||||
r.Static("/static", "./web/static")
|
||||
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 := r.Group("/api")
|
||||
@@ -53,6 +84,10 @@ func main() {
|
||||
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
|
||||
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
|
||||
user.POST("/auth", handler.WechatAuth)
|
||||
// 生辰档案(八字绑定,每用户免费 3 个)
|
||||
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
|
||||
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
|
||||
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
|
||||
}
|
||||
|
||||
// 支付相关
|
||||
@@ -87,13 +122,20 @@ func main() {
|
||||
|
||||
// 管理后台路由(Inertia.js)
|
||||
admin := r.Group("/admin")
|
||||
admin.Use(middleware.AdminAuth())
|
||||
{
|
||||
admin.GET("/", handler.AdminDashboard)
|
||||
admin.GET("/users", handler.AdminUsers)
|
||||
admin.GET("/orders", handler.AdminOrders)
|
||||
admin.GET("/wishes", handler.AdminWishes)
|
||||
admin.GET("/settings", handler.AdminSettings)
|
||||
// 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
|
||||
admin.POST("/login", handler.AdminLogin)
|
||||
admin.POST("/logout", handler.AdminLogout)
|
||||
|
||||
authed := admin.Group("")
|
||||
authed.Use(middleware.AdminAuth())
|
||||
{
|
||||
authed.GET("/", handler.AdminDashboard)
|
||||
authed.GET("/users", handler.AdminUsers)
|
||||
authed.GET("/orders", handler.AdminOrders)
|
||||
authed.GET("/wishes", handler.AdminWishes)
|
||||
authed.GET("/settings", handler.AdminSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
|
||||
+12
-3
@@ -4,7 +4,11 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -35,11 +39,16 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
+27
-3
@@ -9,12 +9,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -33,6 +39,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -48,6 +58,8 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -57,6 +69,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -83,12 +98,11 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
@@ -99,6 +113,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -10,14 +10,17 @@ type Config struct {
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Wechat WechatConfig
|
||||
Admin AdminConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Env string
|
||||
Port string
|
||||
Env string
|
||||
CORSOrigins string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Type string
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
@@ -43,13 +46,21 @@ type WechatConfig struct {
|
||||
MchID string
|
||||
}
|
||||
|
||||
// AdminConfig 管理后台配置
|
||||
type AdminConfig struct {
|
||||
// Password 管理员登录密码;为空时禁用后台登录
|
||||
Password string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Env: getEnv("SERVER_ENV", "development"),
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Env: getEnv("SERVER_ENV", "development"),
|
||||
CORSOrigins: getEnv("CORS_ORIGINS", ""),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Type: getEnv("DB_TYPE", "mysql"),
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "3306"),
|
||||
User: getEnv("DB_USER", "root"),
|
||||
@@ -71,6 +82,9 @@ func Load() *Config {
|
||||
PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
|
||||
MchID: getEnv("WECHAT_PAY_MCHID", ""),
|
||||
},
|
||||
Admin: AdminConfig{
|
||||
Password: getEnv("ADMIN_PASSWORD", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
@@ -13,16 +14,27 @@ var DB *gorm.DB
|
||||
|
||||
// InitDB 初始化数据库连接
|
||||
func InitDB(cfg *Config) error {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
cfg.Database.User,
|
||||
cfg.Database.Password,
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.Name,
|
||||
)
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
|
||||
switch cfg.Database.Type {
|
||||
case "sqlite", "sqlite3":
|
||||
// DB_NAME 作为 SQLite 文件路径,例如 /app/data/lunar.db
|
||||
dbPath := cfg.Database.Name
|
||||
if dbPath == "" {
|
||||
dbPath = "lunar.db"
|
||||
}
|
||||
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
default:
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
cfg.Database.User,
|
||||
cfg.Database.Password,
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.Name,
|
||||
)
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect database: %w", err)
|
||||
}
|
||||
@@ -41,6 +53,7 @@ func autoMigrate() error {
|
||||
if err := DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.UserProfile{},
|
||||
&model.BirthProfile{},
|
||||
&model.Order{},
|
||||
&model.OrderItem{},
|
||||
&model.WishTree{},
|
||||
@@ -56,6 +69,13 @@ func autoMigrate() error {
|
||||
|
||||
// seedDefaultData 初始化默认数据
|
||||
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
|
||||
DB.Model(&model.WishTree{}).Count(&count)
|
||||
@@ -65,9 +85,9 @@ func seedDefaultData() error {
|
||||
|
||||
// 插入默认许愿树
|
||||
trees := []model.WishTree{
|
||||
{Name: "祈福树", Description: "许下美好愿望,祈福平安顺遂", Type: "pine", MaxWishes: 100, Sort: 0, Status: 1},
|
||||
{Name: "姻缘树", Description: "祈求姻缘美满,爱情甜蜜", Type: "sakura", MaxWishes: 50, Sort: 1, Status: 1},
|
||||
{Name: "事业树", Description: "祈愿事业顺利,步步高升", Type: "bamboo", MaxWishes: 80, Sort: 2, Status: 1},
|
||||
{Name: "祈福树", Description: "许下美好愿望,祈福平安顺遂", Type: "pine", Image: "/images/trees/tree-1.jpg", MaxWishes: 100, Sort: 0, Status: 1},
|
||||
{Name: "姻缘树", Description: "祈求姻缘美满,爱情甜蜜", Type: "sakura", Image: "/images/trees/tree-1.jpg", MaxWishes: 50, Sort: 1, 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 {
|
||||
return err
|
||||
|
||||
@@ -1,47 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"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 管理后台首页
|
||||
func AdminDashboard(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "管理后台",
|
||||
"page": "dashboard",
|
||||
})
|
||||
adminPage(c, "管理后台", "dashboard")
|
||||
}
|
||||
|
||||
// AdminUsers 用户管理
|
||||
func AdminUsers(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "用户管理",
|
||||
"page": "users",
|
||||
})
|
||||
adminPage(c, "用户管理", "users")
|
||||
}
|
||||
|
||||
// AdminOrders 订单管理
|
||||
func AdminOrders(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "订单管理",
|
||||
"page": "orders",
|
||||
})
|
||||
adminPage(c, "订单管理", "orders")
|
||||
}
|
||||
|
||||
// AdminWishes 许愿管理
|
||||
func AdminWishes(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "许愿管理",
|
||||
"page": "wishes",
|
||||
})
|
||||
adminPage(c, "许愿管理", "wishes")
|
||||
}
|
||||
|
||||
// AdminSettings 系统设置
|
||||
func AdminSettings(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "系统设置",
|
||||
"page": "settings",
|
||||
})
|
||||
adminPage(c, "系统设置", "settings")
|
||||
}
|
||||
|
||||
@@ -43,14 +43,16 @@ func GetOrderList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetOrderDetail 获取订单详情
|
||||
// GetOrderDetail 获取订单详情(仅限本人订单)
|
||||
func GetOrderDetail(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
id := c.Param("id")
|
||||
orderID, _ := strconv.Atoi(id)
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
order, err := orderService.GetOrderByID(uint(orderID))
|
||||
if err != nil {
|
||||
if err != nil || order.UserID != userID.(uint) {
|
||||
// 不存在与无权访问统一返回 404,避免枚举他人订单
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"msg": "订单不存在",
|
||||
@@ -65,16 +67,17 @@ func GetOrderDetail(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CancelOrder 取消订单
|
||||
// CancelOrder 取消订单(仅限本人待支付订单)
|
||||
func CancelOrder(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
id := c.Param("id")
|
||||
orderID, _ := strconv.Atoi(id)
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
if err := orderService.CancelOrder(uint(orderID)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "取消订单失败",
|
||||
if err := orderService.CancelOrder(userID.(uint), uint(orderID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
+103
-38
@@ -1,14 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
// CreateOrder 创建订单
|
||||
// 安全约束:金额与商品名一律以服务端商品表为准,不信任客户端传入值
|
||||
func CreateOrder(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
@@ -20,11 +26,9 @@ func CreateOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
|
||||
ProductID uint `json:"productId" binding:"required"` // 商品ID
|
||||
ProductName string `json:"productName" binding:"required"` // 商品名称
|
||||
Amount int `json:"amount" binding:"required"` // 金额(分)
|
||||
Content string `json:"content"` // 许愿内容(许愿类型需要)
|
||||
Type string `json:"type" binding:"required"` // wish:许愿 vip:会员
|
||||
ProductID uint `json:"productId" binding:"required"` // 商品ID
|
||||
Content string `json:"content"` // 许愿内容(许愿类型需要)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -35,14 +39,48 @@ func CreateOrder(c *gin.Context) {
|
||||
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{
|
||||
UserID: userID.(uint),
|
||||
Type: req.Type,
|
||||
ProductID: req.ProductID,
|
||||
ProductName: req.ProductName,
|
||||
Amount: req.Amount,
|
||||
ProductID: product.ID,
|
||||
ProductName: product.Name,
|
||||
Amount: product.Price, // 金额以商品表为准
|
||||
Remark: req.Content, // 许愿内容暂存订单,支付成功后才创建许愿
|
||||
}
|
||||
|
||||
if err := orderService.CreateOrder(order); err != nil {
|
||||
@@ -53,19 +91,6 @@ func CreateOrder(c *gin.Context) {
|
||||
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, "")
|
||||
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) {
|
||||
// TODO: 验证微信支付回调签名
|
||||
cfg := config.Load()
|
||||
if cfg.Wechat.PayKey == "" {
|
||||
// 未配置支付密钥时拒绝一切回调,避免裸奔
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"code": "FAIL",
|
||||
"msg": "支付服务未配置",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
OrderNo string `json:"orderNo" binding:"required"`
|
||||
TransactionID string `json:"transactionId" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -104,15 +146,32 @@ func PayNotify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "SUCCESS" {
|
||||
orderService := service.NewOrderService()
|
||||
if err := orderService.HandlePayNotify(req.OrderNo, req.TransactionID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": "FAIL",
|
||||
"msg": "处理失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 验签:恒定时间比较,防时序攻击
|
||||
sign := c.GetHeader("X-Pay-Sign")
|
||||
expected := paySignOf(req.OrderNo, req.TransactionID, cfg.Wechat.PayKey)
|
||||
if sign == "" || !hmac.Equal([]byte(sign), []byte(expected)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": "FAIL",
|
||||
"msg": "签名验证失败",
|
||||
})
|
||||
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{
|
||||
@@ -121,12 +180,18 @@ func PayNotify(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetPayStatus 获取支付状态
|
||||
// GetPayStatus 获取支付状态(支持订单号或订单 ID)
|
||||
func GetPayStatus(c *gin.Context) {
|
||||
orderID := c.Param("orderId")
|
||||
param := c.Param("orderId")
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
|
||||
+207
-31
@@ -1,7 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
@@ -9,7 +15,71 @@ import (
|
||||
"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) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
@@ -23,38 +93,35 @@ func UserLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用微信接口获取 openid
|
||||
// 这里模拟返回
|
||||
openID := "mock_openid_" + req.Code
|
||||
|
||||
userService := service.NewUserService()
|
||||
user, err := userService.GetUserByOpenID(openID)
|
||||
openID, unionID, err := code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "服务器错误",
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "微信登录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果用户不存在,创建新用户
|
||||
if user == nil {
|
||||
user = &model.User{
|
||||
OpenID: openID,
|
||||
Nickname: "微信用户",
|
||||
Status: 1,
|
||||
}
|
||||
if err := userService.CreateUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "创建用户失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
user, err := upsertUser(openID, unionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "创建用户失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 禁用用户不允许登录
|
||||
if user.Status != 1 {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"msg": "账号已被禁用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
cfg := config.Load()
|
||||
userService := service.NewUserService()
|
||||
token, err := userService.GenerateToken(user.ID, cfg.JWT.Secret)
|
||||
if err != nil {
|
||||
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 微信授权(仅返回 openid,session_key 属敏感凭证不下发)
|
||||
func WechatAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
@@ -231,16 +403,20 @@ func WechatAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 调用微信接口获取 openid 和 session_key
|
||||
// 这里模拟返回
|
||||
openID := "mock_openid_" + req.Code
|
||||
openID, _, err := code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "微信授权失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"openid": openID,
|
||||
"sessionKey": "mock_session_key",
|
||||
"openid": openID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 版本信息:由 cmd/main.go 将 CI 的 ldflags 注入值赋入,保证单一数据源
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
CommitSha = "unknown"
|
||||
)
|
||||
|
||||
// VersionInfo 版本信息
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
@@ -17,26 +24,10 @@ type VersionInfo struct {
|
||||
|
||||
// GetVersion 获取服务器版本信息
|
||||
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{
|
||||
Version: version,
|
||||
BuildTime: buildTime,
|
||||
CommitSha: commitSha,
|
||||
GoVersion: "go1.22", // 可以通过 runtime.Version() 获取
|
||||
Version: Version,
|
||||
BuildTime: BuildTime,
|
||||
CommitSha: CommitSha,
|
||||
GoVersion: runtime.Version(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,12 +9,41 @@ import (
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
// CORS 跨域中间件;可通过 CORS_ORIGINS 配置允许的源(逗号分隔),未配置时保持 *
|
||||
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) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
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")
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
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" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
@@ -25,40 +54,38 @@ func CORS() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Logger 日志中间件
|
||||
func Logger() gin.HandlerFunc {
|
||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
return ""
|
||||
})
|
||||
// bearerToken 从 Authorization 头提取 Bearer token,格式错误时返回 ok=false 并已应答
|
||||
func bearerToken(c *gin.Context) (string, bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
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认证中间件
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"msg": "未授权",
|
||||
})
|
||||
c.Abort()
|
||||
tokenString, ok := bearerToken(c)
|
||||
if !ok {
|
||||
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()
|
||||
userService := service.NewUserService()
|
||||
userID, err := userService.ParseToken(tokenString, cfg.JWT.Secret)
|
||||
@@ -71,16 +98,43 @@ func Auth() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户ID存入上下文
|
||||
c.Set("userID", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminAuth 管理员认证中间件
|
||||
// AdminAuth 管理员认证中间件:要求携带 role=admin 的 JWT(Authorization 头或 HttpOnly Cookie)
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,18 @@ type UserProfile struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Wish struct {
|
||||
Position int `gorm:"default:0" json:"position"` // 位置(用于排序/覆盖)
|
||||
Status int `gorm:"default:1" json:"status"` // 1:正常 0:隐藏/删除
|
||||
IsRobot bool `gorm:"default:false" json:"isRobot"` // 是否机器人发布
|
||||
IsTop bool `gorm:"-" json:"isTop"` // 是否顶级位置(运行时计算)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -24,6 +25,7 @@ type WishTree struct {
|
||||
Name string `gorm:"size:50" json:"name"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
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"` // 最大许愿条数
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序
|
||||
Status int `gorm:"default:1" json:"status"`
|
||||
|
||||
@@ -3,7 +3,9 @@ package service
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"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
|
||||
}
|
||||
|
||||
// CancelOrder 取消订单
|
||||
func (s *OrderService) CancelOrder(id uint) error {
|
||||
return s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", "cancelled").Error
|
||||
// CancelOrder 取消订单(仅限本人且仅待支付状态可取消)
|
||||
func (s *OrderService) CancelOrder(userID, id uint) 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 检查订单是否过期
|
||||
@@ -138,14 +149,53 @@ func (s *OrderService) generatePaySign(orderNo string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// HandlePayNotify 处理支付回调
|
||||
// HandlePayNotify 处理支付回调(幂等:已支付订单不重复处理)
|
||||
func (s *OrderService) HandlePayNotify(orderNo, transactionID string) error {
|
||||
// 更新订单状态
|
||||
if err := s.UpdateOrderStatus(orderNo, "paid"); err != nil {
|
||||
return err
|
||||
order, err := s.GetOrderByOrderNo(orderNo)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -70,6 +71,35 @@ func (s *UserService) GetUserProfile(userID uint) (*model.UserProfile, error) {
|
||||
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
|
||||
func (s *UserService) GenerateToken(userID uint, secret string) (string, error) {
|
||||
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 {
|
||||
userID := uint(claims["user_id"].(float64))
|
||||
return userID, nil
|
||||
// 管理员 token 不绑定具体用户,不能当普通用户 token 使用
|
||||
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")
|
||||
}
|
||||
|
||||
// AdminTokenCookie 管理员会话 Cookie 名
|
||||
const AdminTokenCookie = "lunar_admin_token"
|
||||
|
||||
// GenerateAdminToken 签发管理员 JWT(12 小时过期)
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -97,15 +97,21 @@ func (s *WishService) CreateWish(wish *model.Wish) error {
|
||||
return s.db.Create(wish).Error
|
||||
}
|
||||
|
||||
// GetTreeWishes 获取指定树的许愿列表
|
||||
// GetTreeWishes 获取指定树的许愿列表(最新100条,标记顶级位置)
|
||||
func (s *WishService) GetTreeWishes(treeID uint) ([]*model.Wish, error) {
|
||||
var wishes []*model.Wish
|
||||
if err := s.db.Where("tree_id = ? AND status = 1", treeID).
|
||||
Order("position DESC, created_at DESC").
|
||||
Order("created_at DESC").
|
||||
Limit(100).
|
||||
Find(&wishes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算顶级位置:高位置付费许愿且购买后24小时内
|
||||
for _, w := range wishes {
|
||||
w.IsTop = w.Position >= 100 && time.Since(w.CreatedAt) <= 24*time.Hour
|
||||
}
|
||||
|
||||
return wishes, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS wish_trees (
|
||||
name VARCHAR(50) NOT NULL COMMENT '名称',
|
||||
description VARCHAR(255) DEFAULT '' COMMENT '描述',
|
||||
type VARCHAR(20) DEFAULT 'pine' COMMENT '树类型 pine:松树 sakura:樱花 bamboo:竹子',
|
||||
image VARCHAR(255) DEFAULT '' COMMENT '背景图地址',
|
||||
max_wishes INT DEFAULT 100 COMMENT '最大许愿条数',
|
||||
sort INT DEFAULT 0 COMMENT '排序',
|
||||
status TINYINT DEFAULT 1 COMMENT '状态',
|
||||
@@ -96,10 +97,10 @@ CREATE TABLE IF NOT EXISTS wish_products (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许愿商品表';
|
||||
|
||||
-- 插入默认许愿树
|
||||
INSERT IGNORE INTO wish_trees (id, name, description, type, max_wishes, sort) VALUES
|
||||
(1, '祈福树', '许下美好愿望,祈福平安顺遂', 'pine', 100, 0),
|
||||
(2, '姻缘树', '祈求姻缘美满,爱情甜蜜', 'sakura', 50, 1),
|
||||
(3, '事业树', '祈愿事业顺利,步步高升', 'bamboo', 80, 2);
|
||||
INSERT IGNORE INTO wish_trees (id, name, description, type, image, max_wishes, sort) VALUES
|
||||
(1, '祈福树', '许下美好愿望,祈福平安顺遂', 'pine', '/images/trees/tree-1.jpg', 100, 0),
|
||||
(2, '姻缘树', '祈求姻缘美满,爱情甜蜜', 'sakura', '/images/trees/tree-1.jpg', 50, 1),
|
||||
(3, '事业树', '祈愿事业顺利,步步高升', 'bamboo', '/images/trees/tree-1.jpg', 80, 2);
|
||||
|
||||
-- 插入默认许愿商品
|
||||
INSERT IGNORE INTO wish_products (id, name, description, price, duration, position) VALUES
|
||||
|
||||
@@ -7,18 +7,18 @@ echo "Building Lunar Server..."
|
||||
# 创建输出目录
|
||||
mkdir -p bin
|
||||
|
||||
# 编译 Go 后端
|
||||
# 编译 Go 后端(产物名与 CI/deploy.sh 保持一致)
|
||||
echo "Building Go backend..."
|
||||
go build -o bin/lunar-server cmd/main.go
|
||||
go build -o bin/server cmd/main.go
|
||||
|
||||
# 编译前端(如果存在)
|
||||
if [ -d "web" ]; then
|
||||
# 编译前端(仅当存在 package.json 时;当前 web/ 为纯静态资源,无需构建)
|
||||
if [ -f "web/package.json" ]; then
|
||||
echo "Building frontend..."
|
||||
cd web
|
||||
npm install
|
||||
npm ci
|
||||
npm run build
|
||||
cd ..
|
||||
fi
|
||||
|
||||
echo "Build complete!"
|
||||
echo "Output: bin/lunar-server"
|
||||
echo "Output: bin/server"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user