Merge branch 'yongxu-dev' into devlop
# Conflicts: # miniprogram/app.js # miniprogram/app.json # miniprogram/pages/chapters/chapters.js # miniprogram/pages/chapters/chapters.wxml # miniprogram/pages/chapters/chapters.wxss # miniprogram/pages/index/index.js # miniprogram/pages/index/index.wxml # miniprogram/pages/match/match.js # miniprogram/pages/my/my.js # miniprogram/pages/my/my.wxml # miniprogram/pages/read/read.js # miniprogram/pages/read/read.wxml # miniprogram/pages/read/read.wxss # miniprogram/pages/referral/referral.js # miniprogram/pages/search/search.js # miniprogram/pages/vip/vip.js # miniprogram/pages/wallet/wallet.wxml # miniprogram/project.private.config.json # soul-admin/dist/index.html # soul-admin/src/pages/dashboard/DashboardPage.tsx # soul-admin/src/pages/settings/SettingsPage.tsx # soul-api/go.mod # soul-api/internal/handler/admin_dashboard.go # soul-api/internal/handler/db.go # soul-api/wechat/info.log # 开发文档/10、项目管理/运营与变更.md # 开发文档/README.md
This commit is contained in:
@@ -1,101 +1,5 @@
|
||||
# 团队共享 经验记录 - 2026-03-10
|
||||
|
||||
## 在 Windows 上一键启动 macOS 虚拟机(“龙虾”智能体经验)
|
||||
|
||||
### 场景 / 问题
|
||||
|
||||
- 成员希望在 **Windows 10/11** 上通过 **Docker** 一键安装 macOS,用于演示 / 测试。
|
||||
- 实际上:
|
||||
- **macOS 不能在 Docker 中运行**(Docker 只跑 Linux 容器,没有合法的 macOS 镜像)。
|
||||
- 唯一可行路径是:**WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM**。
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **明确技术与法律边界**
|
||||
- 不支持也不承诺在 Docker 中直接跑 macOS。
|
||||
- 统一采用「**WSL2 + QEMU/KVM + OneClick**」这个方案,仅用于演示 / 测试。
|
||||
|
||||
2. **固定目录与流程约定**
|
||||
- Windows 侧统一放在:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 侧路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 内部结构:
|
||||
- `OneClick-macOS-Simple-KVM/`
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
|
||||
3. **获取 OneClick 源码时优先走 zip,而不是 git clone**
|
||||
- 直接 `git clone` 很容易在国内网络环境下触发 `GnuTLS recv error (-110)` 等 TLS 超时。
|
||||
- 统一约定使用:
|
||||
- `https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master`
|
||||
- 然后在 WSL 中:
|
||||
- `curl + unzip` → 解压 → 重命名为 `OneClick-macOS-Simple-KVM`。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
- 在 Ubuntu-24.04 内安装:
|
||||
- `qemu-system qemu-utils python3 python3-pip cpu-checker`
|
||||
- 使用 `kvm-ok` 检查:
|
||||
- 期望输出:`/dev/kvm exists` + `KVM acceleration can be used`。
|
||||
- 若 `nested` 为 `N` 且需要嵌套虚拟化,使用 `.wslconfig` 打开 nested。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
- 通过 `python3 fetch-macOS-v2.py -s ventura` 下载官方 Recovery 镜像。
|
||||
- 将 `RecoveryImage.dmg` 重命名为 `BaseSystem.dmg`,再用 `qemu-img convert` 生成 `BaseSystem.img`。
|
||||
- 验收标准:
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
|
||||
6. **以 HEADLESS + VNC 方式启动 VM**
|
||||
- 使用 `sudo HEADLESS=1 ./basic.sh` 启动 QEMU。
|
||||
- 在 Windows 中确认 `127.0.0.1:5900` 端口监听。
|
||||
- 统一告知使用 VNC 客户端连接 `localhost:5900`,再在图形界面内完成 macOS 安装向导。**用户环境已采用 TightVNC**,与 RealVNC Viewer 等方式等效。
|
||||
|
||||
7. **WSL 卡死与多 wsl 进程清理策略**
|
||||
- 当 `wsl --shutdown` 卡住、或者有大量 `wsl` 进程残留时:
|
||||
- 使用 PowerShell `Get-Process wsl | Stop-Process -Force` 清理。
|
||||
- 再执行 `wsl --shutdown` + `wsl -l -v` 验证状态恢复。
|
||||
|
||||
### 对应 Skill / 智能体
|
||||
|
||||
- 新建 Skill:`.cursor/skills/lobster-macos-vm/SKILL.md`
|
||||
- 技能名:`lobster-macos-vm`
|
||||
- 智能体名(对外):**“龙虾”**
|
||||
- 职责:当用户在 Windows 上提出安装 / 维护 macOS 虚拟机的需求时,统一按该 Skill 流程执行:
|
||||
- 解释 Docker 不可用 → 切换到 WSL2 + QEMU 方案。
|
||||
- 固定目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → HEADLESS 启动 → 引导 VNC 安装。
|
||||
|
||||
- 计划脚本化:
|
||||
- 在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 中实现一键部署脚本,封装上述流程,供“龙虾”及人类成员复用。
|
||||
|
||||
### 用户环境补充
|
||||
|
||||
- **VNC 客户端**:当前环境使用 **TightVNC** 连接 `localhost:5900`,已写入龙虾 Skill,后续回复可一并推荐 TightVNC / RealVNC / TigerVNC 等。
|
||||
|
||||
### 安装完成与使用规范快照
|
||||
|
||||
- 当前虚拟机参数:
|
||||
- 内存:8G(`-m 8G`)
|
||||
- CPU:1 颗 CPU × 4 核 × 2 线程(共 8 线程)
|
||||
- 系统盘:`macOS.qcow2`,逻辑容量 64G,可后续通过 `qemu-img resize` 扩容。
|
||||
- 启动方式快照:
|
||||
- 优先使用 `C:\Users\{USERNAME}\Mycontent\macos-vm\一键启动-macOS虚拟机.bat`
|
||||
- bat 内部做两件事:
|
||||
1. 启动 WSL → 进入 `OneClick-macOS-Simple-KVM` → `sudo HEADLESS=1 ./basic.sh`
|
||||
2. 等待约 10 秒后自动启动 TightVNC Viewer 连接 `localhost:5900`
|
||||
- 关闭规则:不要关名为「macOS 虚拟机 - 勿关此窗口」的终端窗口,否则虚拟机会被强制关闭。
|
||||
- 安装阶段经验:
|
||||
- 安装过程中多次重启属正常,出现 `X86PlatformPlugin::systemWillShutdown!` / `IOPlatformHaltRestartAction -> AppleSMC` 说明在正常关机/重启。
|
||||
- OpenCore 菜单的选择顺序:
|
||||
- 安装阶段:多次选择 `macOS Installer` 直至不再出现安装向导。
|
||||
- 安装完成:只选系统盘(如 `Macintosh HD`),不要选 `mac - Data`。
|
||||
- 安装完成后,为避免反复从恢复盘启动,`basic.sh` 默认不再挂载 `BaseSystem.img`;需要重装时可通过 `INSTALL_MEDIA=1 HEADLESS=1 ./basic.sh` 临时挂载。
|
||||
|
||||
### 适用角色
|
||||
|
||||
- 后端 / 运维:需要在本地或服务器上快速拉起 macOS VM 做兼容性验证或演示。
|
||||
- 团队:对外说明 **“我们不支持 Docker macOS,统一用龙虾方案”**。
|
||||
|
||||
---
|
||||
|
||||
## 管理端迁移 Mycontent-temp:菜单/布局新规范基线
|
||||
|
||||
### 决议(团队共享)
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
|
||||
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」;match 弹窗已修正 |
|
||||
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx,个人中心类页面布局规范 |
|
||||
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的"龙虾"方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
|
||||
| 2026-03-10 | 小程序 | 最佳实践 | miniprogram-dev SKILL §my | my.js 阅读统计改为后端接口(loadDashboardStats),禁止用随机数时间/标题占位 |
|
||||
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | 聚合接口三处修复:recentChapters 去重、totalReadMinutes 最小1分钟、DB 错误返回 500 |
|
||||
| 2026-03-10 | 团队 | 方法论 | - | 新旧版代码对比:以功能完整性为基准,批量 diff + 分类取舍,不以日期判优劣 |
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
name: lobster-macos-vm
|
||||
description: Automates provisioning and troubleshooting of macOS virtual machines on Windows using WSL2, QEMU/KVM, and the OneClick-macOS-Simple-KVM project. Use when the user mentions 龙虾, macOS 虚拟机, 一键安装苹果系统 on Windows, or needs to re-deploy the Ventura/Sonoma VM.
|
||||
---
|
||||
|
||||
# 龙虾(lobster-macos-vm)
|
||||
|
||||
> 专门负责:在 **Windows 10/11** 上,通过 **WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM** 自动拉起一台 macOS 虚拟机(Ventura 为默认),并处理常见网络 / WSL / KVM 问题。
|
||||
|
||||
## 触发场景
|
||||
|
||||
- 用户提到:**“龙虾”**、**“苹果系统虚拟机”**、**“Windows 上跑 macOS”**、**“一键安装 macOS 虚拟机”**
|
||||
- 用户需要:在 Windows 上**演示 / 测试** macOS,而不是 Docker 容器
|
||||
- 用户遇到:WSL 安装失败、`0x80072ee2` 网络错误、`kvm-ok`、`nested` 配置问题、`git clone` TLS 超时、OneClick 项目下载问题
|
||||
|
||||
## 核心能力
|
||||
|
||||
1. **环境检测与前置说明**
|
||||
- 明确告诉用户:**macOS 不能在 Docker 里运行,必须用 WSL2 + 虚拟机**。
|
||||
- 检查:
|
||||
- `wsl -l -v` → 是否有 `Ubuntu-24.04`,是否为 Version 2
|
||||
- `docker --version` 仅作背景信息,不作为必需条件
|
||||
- 若缺失 WSL2:
|
||||
- 指导用户在**管理员 PowerShell**中执行:
|
||||
- `wsl --install`
|
||||
- 重启后执行 `wsl --install -d Ubuntu-24.04 --web-download`(必要时)
|
||||
|
||||
2. **固定部署目录约定**
|
||||
- 所有与 macOS VM 相关的文件,统一放到:
|
||||
- Windows 路径:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 该目录下结构:
|
||||
- `OneClick-macOS-Simple-KVM/`(从 GitHub 下载的项目)
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
- 可能还有 `OneClick.zip` 等临时文件
|
||||
|
||||
3. **获取 OneClick 源码(优先 zip,退而求其次 git)**
|
||||
|
||||
优先使用 **codeload.zip**,避免长时间 `git clone` TLS 超时:
|
||||
|
||||
- 在 `macos-vm/` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y curl unzip
|
||||
rm -rf OneClick-macOS-Simple-KVM OneClick.zip
|
||||
curl -L --retry 8 --retry-delay 2 --connect-timeout 20 --max-time 600 \
|
||||
-o OneClick.zip \
|
||||
https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master
|
||||
unzip -q OneClick.zip
|
||||
mv OneClick-macOS-Simple-KVM-master OneClick-macOS-Simple-KVM
|
||||
```
|
||||
|
||||
仅当用户网络环境允许且确有需要时,才尝试:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
```
|
||||
|
||||
出现 `GnuTLS recv error (-110)` 或 TLS 断开时,**不要重复 git clone**,改走 zip 方案。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
|
||||
在 `Ubuntu-24.04` 内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
预期输出:
|
||||
|
||||
- `INFO: /dev/kvm exists`
|
||||
- `KVM acceleration can be used`
|
||||
|
||||
若 `nested` 为 `N` 但 `kvm-ok` 正常:
|
||||
|
||||
- 提示用户在 `C:\Users\{USERNAME}\.wslconfig` 中写入:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
nestedVirtualization=true
|
||||
kernel=C:\\Users\\{USERNAME}\\bzImage
|
||||
debugConsole=true
|
||||
pageReporting=true
|
||||
kernelCommandLine=intel_iommu=on iommu=pt kvm.ignore_msrs=1 kvm-intel.nested=1 kvm-intel.ept=1 kvm-intel.emulate_invalid_guest_state=0 kvm-intel.enable_shadow_vmcs=1 kvm-intel.enable_apicv=1
|
||||
```
|
||||
|
||||
并执行 `wsl --shutdown` 之后重试。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/{USERNAME}/Mycontent/macos-vm/OneClick-macOS-Simple-KVM
|
||||
chmod +x *.sh *.py
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
python3 fetch-macOS-v2.py -s ventura
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
ls -lah BaseSystem.* macOS.qcow2
|
||||
```
|
||||
|
||||
成功标志:
|
||||
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
- `macOS.qcow2` 已存在(几十 KB 起步)
|
||||
|
||||
6. **启动虚拟机(headless + VNC: localhost:5900)**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
常见日志:
|
||||
|
||||
- ALSA / audio 报错(没有声卡驱动)→ **可以忽略**
|
||||
- `BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00017"...` → 已经开始从虚拟硬盘启动
|
||||
|
||||
在 Windows 侧确认端口:
|
||||
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 5900 -State Listen
|
||||
```
|
||||
|
||||
若监听正常,提示用户:
|
||||
|
||||
- 安装 VNC 客户端并连接 `localhost:5900`,进入 macOS 安装向导(磁盘工具抹盘 + 安装系统)。
|
||||
- **常用 VNC 客户端**:RealVNC Viewer、**TightVNC**(用户环境已采用)、TigerVNC 等均可,连接地址均为 `localhost:5900`。
|
||||
|
||||
7. **WSL / 网络故障排查**
|
||||
|
||||
- 若 `wsl` 进程过多、`wsl --shutdown` 卡死:
|
||||
- 在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
Get-Process -Name wsl -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
wsl --shutdown
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
- 若 `wsl --install` 报 `0x80072ee2` 或无法访问 `raw.githubusercontent.com`:
|
||||
- 提醒用户这是 **网络 / DNS 问题**,可尝试:
|
||||
- 切换 DNS 到 `8.8.8.8`
|
||||
- 使用合规代理 / VPN
|
||||
- 使用 `--web-download` 方式安装发行版:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04 --web-download
|
||||
```
|
||||
|
||||
8. **Python 一键脚本(lobster_macos_vm.py)协同**
|
||||
|
||||
当仓库中存在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 时:
|
||||
|
||||
- 优先引导用户在 **PowerShell** 中执行:
|
||||
|
||||
```powershell
|
||||
python C:\Users\{USERNAME}\Mycontent\macos-vm\lobster_macos_vm.py
|
||||
```
|
||||
|
||||
- 该脚本应负责:
|
||||
- 检查 / 安装 WSL2 + Ubuntu-24.04(必要时提示用户重启)
|
||||
- 确保 `C:\Users\{USERNAME}\Mycontent\macos-vm` 目录存在
|
||||
- 在 WSL 内下载 OneClick 源码 zip、解压到固定目录
|
||||
- 安装 QEMU / Python 依赖并检查 `kvm-ok`
|
||||
- 下载 Ventura 恢复镜像并生成 `BaseSystem.img`
|
||||
- 启动 `sudo HEADLESS=1 ./basic.sh`
|
||||
- 输出清晰的步骤说明(包括如何用 VNC 连接)
|
||||
|
||||
## 使用示例
|
||||
|
||||
- 用户说:「**龙虾,帮我在这台 Windows 上一键装一个 macOS 虚拟机,用来演示**」
|
||||
- 按上述步骤依次执行:环境检测 → 创建 `macos-vm` 目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → 启动虚拟机,并提醒用户用 VNC 连 `localhost:5900` 完成图形安装。
|
||||
|
||||
- 用户说:「**龙虾,之前的 macOS 虚拟机挂了,重装一遍**」
|
||||
- 复用相同目录和镜像文件,必要时重新下载 `BaseSystem.dmg`,再启动 `HEADLESS=1 ./basic.sh`。
|
||||
|
||||
@@ -4,54 +4,35 @@
|
||||
*/
|
||||
|
||||
const { parseScene } = require('./utils/scene.js')
|
||||
const { checkAndExecute } = require('./utils/ruleEngine.js')
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://soulapi.quwanzhi.com'
|
||||
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
|
||||
const DEFAULT_MCH_ID = '1318592501'
|
||||
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
|
||||
|
||||
function getRuntimeBootstrapConfig() {
|
||||
try {
|
||||
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
|
||||
return {
|
||||
baseUrl: extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || DEFAULT_BASE_URL,
|
||||
appId: extCfg.appId || DEFAULT_APP_ID,
|
||||
mchId: extCfg.mchId || DEFAULT_MCH_ID,
|
||||
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
|
||||
}
|
||||
} catch (_) {
|
||||
return {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
appId: DEFAULT_APP_ID,
|
||||
mchId: DEFAULT_MCH_ID,
|
||||
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapConfig = getRuntimeBootstrapConfig()
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
// 运行配置:优先外部配置/缓存,其次默认值
|
||||
baseUrl: bootstrapConfig.baseUrl,
|
||||
appId: bootstrapConfig.appId,
|
||||
// API 基础地址:开发时修改下面一行切换环境
|
||||
baseUrl: "https://soulapi.quwanzhi.com",
|
||||
// baseUrl: 'http://localhost:8080', // 开发
|
||||
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
|
||||
// 小程序配置 - 真实AppID
|
||||
appId: DEFAULT_APP_ID,
|
||||
|
||||
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
|
||||
withdrawSubscribeTmplId: bootstrapConfig.withdrawSubscribeTmplId,
|
||||
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID,
|
||||
|
||||
// 微信支付配置
|
||||
mchId: bootstrapConfig.mchId,
|
||||
mchId: DEFAULT_MCH_ID,
|
||||
|
||||
// 用户信息
|
||||
userInfo: null,
|
||||
openId: null, // 微信openId,支付必需
|
||||
isLoggedIn: false,
|
||||
|
||||
// 书籍数据
|
||||
// 书籍数据(bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters)
|
||||
bookData: null,
|
||||
totalSections: 0,
|
||||
supportWechat: '',
|
||||
totalSections: 62,
|
||||
|
||||
// 购买记录
|
||||
purchasedSections: [],
|
||||
@@ -79,6 +60,7 @@ App({
|
||||
systemInfo: null,
|
||||
statusBarHeight: 44,
|
||||
navBarHeight: 88,
|
||||
capsuleRightPadding: 96, // 胶囊右侧留白(px),getSystemInfo 会按 menuButton 计算
|
||||
|
||||
// TabBar相关
|
||||
currentTab: 0,
|
||||
@@ -88,11 +70,24 @@ App({
|
||||
isSinglePageMode: false,
|
||||
|
||||
// 更新检测:上次检测时间戳,避免频繁请求
|
||||
lastUpdateCheck: 0
|
||||
lastUpdateCheck: 0,
|
||||
// mpConfig 上次刷新时间戳(onShow 节流,避免频繁请求)
|
||||
lastMpConfigCheck: 0,
|
||||
|
||||
// 审核模式:后端 /api/miniprogram/config 返回 auditMode=true 时隐藏所有支付相关UI
|
||||
auditMode: false,
|
||||
// 客服/微信:mp_config 返回 supportWechat
|
||||
supportWechat: '',
|
||||
// config 统一缓存(5min),减少重复请求
|
||||
configCache: null,
|
||||
configCacheExpires: 0
|
||||
},
|
||||
|
||||
onLaunch(options) {
|
||||
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
|
||||
// 加载 iconfont(字体图标)。注意:小程序不支持在 wxss 里用本地 @font-face 引用字体文件,
|
||||
// 需使用 loadFontFace 动态加载(字体文件建议走 https CDN)。
|
||||
this.loadIconFont()
|
||||
// 获取系统信息
|
||||
this.getSystemInfo()
|
||||
|
||||
@@ -108,10 +103,11 @@ App({
|
||||
|
||||
// 检查登录状态
|
||||
this.checkLoginStatus()
|
||||
this.loadRuntimeConfig()
|
||||
|
||||
// 加载书籍数据
|
||||
this.loadBookData()
|
||||
// 加载 mpConfig(appId、mchId、withdrawSubscribeTmplId 等,失败时保留默认值)
|
||||
this.loadMpConfig()
|
||||
|
||||
// 检查更新
|
||||
this.checkUpdate()
|
||||
@@ -119,11 +115,34 @@ App({
|
||||
// 处理分享参数(推荐码绑定)
|
||||
this.handleReferralCode(options)
|
||||
},
|
||||
|
||||
// 动态加载 iconfont(避免本地 @font-face 触发 do-not-use-local-path)
|
||||
loadIconFont() {
|
||||
if (!wx.loadFontFace) return
|
||||
// 来自 iconfont 项目(Project id 5142223)
|
||||
// 线上/真机需把 at.alicdn.com 加入「downloadFile 合法域名」
|
||||
const urlWoff2 = 'https://at.alicdn.com/t/c/font_5142223_1sq6pv9vvbt.woff2'
|
||||
wx.loadFontFace({
|
||||
family: 'iconfont',
|
||||
source: `url("${urlWoff2}")`,
|
||||
global: true,
|
||||
success: () => {},
|
||||
fail: (e) => {
|
||||
console.warn('[Iconfont] loadFontFace failed:', e)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// 小程序显示时:处理分享参数、检测更新(从后台切回时)
|
||||
// 小程序显示时:处理分享参数、检测更新、刷新审核模式(从后台切回时)
|
||||
onShow(options) {
|
||||
this.handleReferralCode(options)
|
||||
this.checkUpdate()
|
||||
// 从后台切回时仅刷新审核模式(轻量接口 /config/audit-mode),节流 30 秒
|
||||
const now = Date.now()
|
||||
if (!this.globalData.lastMpConfigCheck || now - this.globalData.lastMpConfigCheck > 30 * 1000) {
|
||||
this.globalData.lastMpConfigCheck = now
|
||||
this.getAuditMode()
|
||||
}
|
||||
},
|
||||
|
||||
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref),与 utils/scene 解析闭环
|
||||
@@ -230,37 +249,6 @@ App({
|
||||
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
|
||||
},
|
||||
|
||||
// 判断用户资料是否完善(昵称 + 头像)
|
||||
_isProfileIncomplete(user) {
|
||||
if (!user) return true
|
||||
const nickname = (user.nickname || '').trim()
|
||||
const avatar = (user.avatar || '').trim()
|
||||
const isDefaultNickname = !nickname || nickname === '微信用户'
|
||||
const noAvatar = !avatar
|
||||
return isDefaultNickname || noAvatar
|
||||
},
|
||||
|
||||
// 登录后若资料未完善,引导跳转到资料编辑页
|
||||
_ensureProfileCompletedAfterLogin(user) {
|
||||
try {
|
||||
if (!user || !this._isProfileIncomplete(user)) return
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
// 避免在资料页内重复跳转
|
||||
if (current && current.route === 'pages/profile-edit/profile-edit') return
|
||||
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
} catch (e) {
|
||||
console.warn('[App] 跳转资料编辑页失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 根据业务 id 从 bookData 查 mid(用于跳转)
|
||||
getSectionMid(sectionId) {
|
||||
const list = this.globalData.bookData || []
|
||||
const ch = list.find(c => c.id === sectionId)
|
||||
return ch?.mid || 0
|
||||
},
|
||||
|
||||
// 获取当前用户的邀请码(用于分享带 ref,未登录返回空字符串)
|
||||
getMyReferralCode() {
|
||||
@@ -295,10 +283,12 @@ App({
|
||||
this.globalData.isSinglePageMode = true
|
||||
}
|
||||
|
||||
// 计算导航栏高度
|
||||
// 计算导航栏高度与胶囊避让
|
||||
const menuButton = wx.getMenuButtonBoundingClientRect()
|
||||
if (menuButton) {
|
||||
this.globalData.navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + systemInfo.statusBarHeight
|
||||
// 胶囊右侧留白(px),供自定义导航栏避开胶囊
|
||||
this.globalData.capsuleRightPadding = (systemInfo.windowWidth || 375) - menuButton.left + 8
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取系统信息失败:', e)
|
||||
@@ -351,42 +341,131 @@ App({
|
||||
}
|
||||
},
|
||||
|
||||
async loadRuntimeConfig() {
|
||||
// 加载书籍元数据(totalSections),不再预加载 all-chapters
|
||||
async loadBookData() {
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
|
||||
const mpConfig = res?.mpConfig || {}
|
||||
this.globalData.baseUrl = mpConfig.apiDomain || this.globalData.baseUrl
|
||||
this.globalData.appId = mpConfig.appId || this.globalData.appId
|
||||
this.globalData.mchId = mpConfig.mchId || this.globalData.mchId
|
||||
this.globalData.withdrawSubscribeTmplId = mpConfig.withdrawSubscribeTmplId || this.globalData.withdrawSubscribeTmplId
|
||||
this.globalData.supportWechat = mpConfig.supportWechat || mpConfig.customerWechat || mpConfig.serviceWechat || ''
|
||||
try {
|
||||
wx.setStorageSync('apiBaseUrl', this.globalData.baseUrl)
|
||||
} catch (_) {}
|
||||
const res = await this.request({ url: '/api/miniprogram/book/parts', silent: true })
|
||||
if (res?.success && res.totalSections != null) {
|
||||
this.globalData.totalSections = res.totalSections
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] 加载运行配置失败,继续使用默认配置:', e)
|
||||
try {
|
||||
const statsRes = await this.request({ url: '/api/miniprogram/book/stats', silent: true })
|
||||
if (statsRes?.success && statsRes?.data?.totalChapters != null) {
|
||||
this.globalData.totalSections = statsRes.data.totalChapters
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
|
||||
// 加载书籍数据
|
||||
async loadBookData() {
|
||||
/**
|
||||
* 获取 config(统一缓存 5min,各页优先读缓存)
|
||||
* 使用拆分接口 core + audit-mode,体积更小、审核模式独立刷新
|
||||
* @param {boolean} forceRefresh - 强制刷新,跳过缓存
|
||||
* @returns {Promise<object|null>} 完整 config 或 null
|
||||
*/
|
||||
async getConfig(forceRefresh = false) {
|
||||
const now = Date.now()
|
||||
const CACHE_TTL = 5 * 60 * 1000
|
||||
if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) {
|
||||
return this.globalData.configCache
|
||||
}
|
||||
try {
|
||||
// 先从缓存加载
|
||||
const cachedData = wx.getStorageSync('bookData')
|
||||
if (cachedData) {
|
||||
this.globalData.bookData = cachedData
|
||||
}
|
||||
|
||||
// 从服务器获取最新数据
|
||||
const res = await this.request('/api/miniprogram/book/all-chapters')
|
||||
if (res && (res.data || res.chapters)) {
|
||||
const chapters = res.data || res.chapters || []
|
||||
this.globalData.bookData = chapters
|
||||
this.globalData.totalSections = res.total || chapters.length || 0
|
||||
wx.setStorageSync('bookData', chapters)
|
||||
const [coreRes, auditRes] = await Promise.all([
|
||||
this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }),
|
||||
this.request({ url: '/api/miniprogram/config/audit-mode', silent: true, timeout: 3000 })
|
||||
])
|
||||
if (coreRes) {
|
||||
const auditMode = auditRes && typeof auditRes.auditMode === 'boolean' ? auditRes.auditMode : false
|
||||
const mp = (coreRes.mpConfig && typeof coreRes.mpConfig === 'object') ? { ...coreRes.mpConfig } : {}
|
||||
mp.auditMode = auditMode
|
||||
const res = {
|
||||
success: coreRes.success,
|
||||
prices: coreRes.prices,
|
||||
features: coreRes.features,
|
||||
userDiscount: coreRes.userDiscount,
|
||||
mpConfig: mp
|
||||
}
|
||||
this.globalData.configCache = res
|
||||
this.globalData.configCacheExpires = now + CACHE_TTL
|
||||
return res
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载书籍数据失败:', e)
|
||||
if (this.globalData.configCache) return this.globalData.configCache
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取阅读页扩展配置(linkTags、linkedMiniprograms),懒加载
|
||||
*/
|
||||
async getReadExtras() {
|
||||
if (Array.isArray(this.globalData.linkTagsConfig) && this.globalData.linkTagsConfig.length > 0) {
|
||||
return {
|
||||
linkTags: this.globalData.linkTagsConfig,
|
||||
linkedMiniprograms: this.globalData.linkedMiniprograms || []
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config/read-extras', silent: true, timeout: 5000 })
|
||||
if (res) {
|
||||
if (Array.isArray(res.linkTags)) this.globalData.linkTagsConfig = res.linkTags
|
||||
if (Array.isArray(res.linkedMiniprograms)) this.globalData.linkedMiniprograms = res.linkedMiniprograms
|
||||
return res
|
||||
}
|
||||
} catch (e) {}
|
||||
return { linkTags: [], linkedMiniprograms: [] }
|
||||
},
|
||||
|
||||
/**
|
||||
* 仅刷新审核模式(从后台切回时用,轻量)
|
||||
*/
|
||||
async getAuditMode() {
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config/audit-mode', silent: true, timeout: 3000 })
|
||||
if (res && typeof res.auditMode === 'boolean') {
|
||||
this.globalData.auditMode = res.auditMode
|
||||
if (this.globalData.configCache && this.globalData.configCache.mpConfig) {
|
||||
this.globalData.configCache.mpConfig.auditMode = res.auditMode
|
||||
}
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
pages.forEach(p => {
|
||||
if (p && p.data && 'auditMode' in p.data) {
|
||||
p.setData({ auditMode: res.auditMode })
|
||||
}
|
||||
})
|
||||
} catch (_) {}
|
||||
return res.auditMode
|
||||
}
|
||||
} catch (e) {}
|
||||
return this.globalData.auditMode
|
||||
},
|
||||
|
||||
// 加载 mpConfig(appId、mchId、withdrawSubscribeTmplId、auditMode、supportWechat 等),失败时保留 globalData 默认值
|
||||
async loadMpConfig() {
|
||||
try {
|
||||
const res = await this.getConfig()
|
||||
if (!res) return
|
||||
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
|
||||
if (mp && typeof mp === 'object') {
|
||||
if (mp.appId) this.globalData.appId = mp.appId
|
||||
if (mp.mchId) this.globalData.mchId = mp.mchId
|
||||
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
|
||||
this.globalData.auditMode = !!mp.auditMode
|
||||
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
|
||||
// 通知当前已加载的页面刷新 auditMode(从后台切回时配置更新后立即生效)
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
pages.forEach(p => {
|
||||
if (p && p.data && 'auditMode' in p.data) {
|
||||
p.setData({ auditMode: this.globalData.auditMode || false })
|
||||
}
|
||||
})
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -444,6 +523,7 @@ App({
|
||||
|
||||
/**
|
||||
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
|
||||
* GET 请求 200ms 内相同 url 去重,避免并发重复请求。
|
||||
* @param {string|object} urlOrOptions - 接口路径,或 { url, method, data, header, silent }
|
||||
* @param {object} options - { method, data, header, silent }
|
||||
* @param {boolean} options.silent - 为 true 时不弹窗,仅 reject(用于静默请求如访问统计)
|
||||
@@ -458,6 +538,7 @@ App({
|
||||
} else {
|
||||
url = ''
|
||||
}
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const silent = !!options.silent
|
||||
const showError = (msg) => {
|
||||
if (!silent && msg) {
|
||||
@@ -465,9 +546,17 @@ App({
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token')
|
||||
// GET 短时去重:相同 url 的并发请求共享同一 promise
|
||||
if (method === 'GET') {
|
||||
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
|
||||
const pending = this._requestPending || (this._requestPending = {})
|
||||
if (pending[dedupKey]) {
|
||||
return pending[dedupKey].promise
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token')
|
||||
wx.request({
|
||||
url: this.globalData.baseUrl + url,
|
||||
method: options.method || 'GET',
|
||||
@@ -514,6 +603,14 @@ App({
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (method === 'GET') {
|
||||
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
|
||||
const pending = this._requestPending || (this._requestPending = {})
|
||||
pending[dedupKey] = { promise, ts: Date.now() }
|
||||
promise.finally(() => { delete pending[dedupKey] })
|
||||
}
|
||||
return promise
|
||||
},
|
||||
|
||||
// 登录方法 - 获取openId用于支付(加固错误处理,避免审核报“登录报错”)
|
||||
@@ -562,8 +659,8 @@ App({
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
// 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎)
|
||||
checkAndExecute('after_login', null)
|
||||
}
|
||||
|
||||
return res.data
|
||||
@@ -624,8 +721,8 @@ App({
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
// 登录后引导完善资料(规则引擎接管)
|
||||
checkAndExecute('after_login', null)
|
||||
}
|
||||
return res.data.openId
|
||||
}
|
||||
@@ -636,6 +733,13 @@ App({
|
||||
return null
|
||||
},
|
||||
|
||||
// 模拟登录已废弃 - 不再使用
|
||||
// 现在必须使用真实的微信登录获取openId作为唯一标识
|
||||
mockLogin() {
|
||||
console.warn('[App] mockLogin已废弃,请使用真实登录')
|
||||
return null
|
||||
},
|
||||
|
||||
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
|
||||
async loginWithPhone(phoneCode) {
|
||||
if (!this.ensureFullAppForAuth()) {
|
||||
@@ -671,8 +775,8 @@ App({
|
||||
this.bindReferralCode(pendingRef)
|
||||
}
|
||||
|
||||
// 登录后引导完善资料
|
||||
this._ensureProfileCompletedAfterLogin(user)
|
||||
// 登录后引导完善资料(规则引擎接管)
|
||||
checkAndExecute('after_login', null)
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"icon": "/components/icon/icon"
|
||||
},
|
||||
"pages": [
|
||||
"pages/chapters/chapters",
|
||||
"pages/index/index",
|
||||
@@ -6,7 +9,6 @@
|
||||
"pages/my/my",
|
||||
"pages/read/read",
|
||||
"pages/link-preview/link-preview",
|
||||
"pages/about/about",
|
||||
"pages/agreement/agreement",
|
||||
"pages/privacy/privacy",
|
||||
"pages/referral/referral",
|
||||
@@ -16,13 +18,17 @@
|
||||
"pages/addresses/addresses",
|
||||
"pages/addresses/edit",
|
||||
"pages/withdraw-records/withdraw-records",
|
||||
"pages/wallet/wallet",
|
||||
"pages/vip/vip",
|
||||
"pages/member-detail/member-detail",
|
||||
"pages/mentors/mentors",
|
||||
"pages/mentor-detail/mentor-detail",
|
||||
"pages/profile-show/profile-show",
|
||||
"pages/profile-edit/profile-edit",
|
||||
"pages/wallet/wallet"
|
||||
"pages/avatar-nickname/avatar-nickname",
|
||||
"pages/gift-pay/detail",
|
||||
"pages/gift-pay/list",
|
||||
"pages/gift-pay/redemption-detail"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
@@ -57,7 +63,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"usingComponents": {},
|
||||
"navigateToMiniProgramAppIdList": [
|
||||
"wx6489c26045912fe1",
|
||||
"wx3d15ed02e98b04e3"
|
||||
|
||||
82
miniprogram/components/env-switch/env-switch.js
Normal file
82
miniprogram/components/env-switch/env-switch.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 开发环境专用:可拖拽的 baseURL 切换悬浮按钮
|
||||
* 正式环境(release)不显示
|
||||
*/
|
||||
const PRODUCTION_URL = 'https://soulapi.quwanzhi.com'
|
||||
const STORAGE_KEY = 'apiBaseUrl'
|
||||
const POSITION_KEY = 'envSwitchPosition'
|
||||
|
||||
const URL_OPTIONS = [
|
||||
{ label: '生产', url: PRODUCTION_URL },
|
||||
{ label: '本地', url: 'http://localhost:8080' },
|
||||
{ label: '测试', url: 'https://souldev.quwanzhi.com' },
|
||||
]
|
||||
|
||||
Component({
|
||||
data: {
|
||||
visible: false,
|
||||
x: 20,
|
||||
y: 120,
|
||||
currentLabel: '生产',
|
||||
areaWidth: 375,
|
||||
areaHeight: 812,
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
try {
|
||||
const accountInfo = wx.getAccountInfoSync?.()
|
||||
const envVersion = accountInfo?.miniProgram?.envVersion || 'release'
|
||||
if (envVersion === 'release') {
|
||||
return
|
||||
}
|
||||
const sys = wx.getSystemInfoSync?.() || {}
|
||||
const areaWidth = sys.windowWidth || 375
|
||||
const areaHeight = sys.windowHeight || 812
|
||||
const saved = wx.getStorageSync(POSITION_KEY)
|
||||
const pos = saved ? JSON.parse(saved) : { x: 20, y: 120 }
|
||||
// 与 app.js 一致:storage 优先,否则用 globalData(已按 env 自动切换)
|
||||
const current = wx.getStorageSync(STORAGE_KEY) || getApp().globalData?.baseUrl || PRODUCTION_URL
|
||||
const opt = URL_OPTIONS.find(o => o.url === current) || URL_OPTIONS[0]
|
||||
this.setData({
|
||||
visible: true,
|
||||
x: pos.x ?? 20,
|
||||
y: pos.y ?? 120,
|
||||
currentLabel: opt.label,
|
||||
areaWidth,
|
||||
areaHeight,
|
||||
})
|
||||
} catch (_) {
|
||||
this.setData({ visible: false })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onTap() {
|
||||
const items = URL_OPTIONS.map(o => o.label)
|
||||
const current = wx.getStorageSync(STORAGE_KEY) || PRODUCTION_URL
|
||||
const idx = URL_OPTIONS.findIndex(o => o.url === current)
|
||||
wx.showActionSheet({
|
||||
itemList: items,
|
||||
success: (res) => {
|
||||
const opt = URL_OPTIONS[res.tapIndex]
|
||||
wx.setStorageSync(STORAGE_KEY, opt.url)
|
||||
const app = getApp()
|
||||
if (app && app.globalData) {
|
||||
app.globalData.baseUrl = opt.url
|
||||
}
|
||||
this.setData({ currentLabel: opt.label })
|
||||
wx.showToast({ title: `已切到${opt.label}`, icon: 'none', duration: 1500 })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onMovableChange(e) {
|
||||
const { x, y } = e.detail
|
||||
if (typeof x === 'number' && typeof y === 'number') {
|
||||
wx.setStorageSync(POSITION_KEY, JSON.stringify({ x, y }))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
3
miniprogram/components/env-switch/env-switch.json
Normal file
3
miniprogram/components/env-switch/env-switch.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
13
miniprogram/components/env-switch/env-switch.wxml
Normal file
13
miniprogram/components/env-switch/env-switch.wxml
Normal file
@@ -0,0 +1,13 @@
|
||||
<movable-area wx:if="{{visible}}" class="env-area" style="width:{{areaWidth}}px;height:{{areaHeight}}px;">
|
||||
<movable-view
|
||||
class="env-btn"
|
||||
direction="all"
|
||||
inertia
|
||||
x="{{x}}"
|
||||
y="{{y}}"
|
||||
bindchange="onMovableChange"
|
||||
bindtap="onTap"
|
||||
>
|
||||
<view class="env-btn-inner">{{currentLabel}}</view>
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
30
miniprogram/components/env-switch/env-switch.wxss
Normal file
30
miniprogram/components/env-switch/env-switch.wxss
Normal file
@@ -0,0 +1,30 @@
|
||||
.env-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.env-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.env-btn-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
@@ -31,7 +31,8 @@ Component({
|
||||
},
|
||||
|
||||
data: {
|
||||
svgData: ''
|
||||
svgData: '',
|
||||
fontGlyph: ''
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
@@ -41,28 +42,119 @@ Component({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// SVG 图标数据映射
|
||||
getSvgPath(name) {
|
||||
const svgMap = {
|
||||
'share': '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
|
||||
|
||||
'arrow-up-right': '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>',
|
||||
|
||||
'chevron-left': '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>',
|
||||
|
||||
'search': '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||
|
||||
'heart': '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>'
|
||||
// iconfont 映射:将业务 name(lucide 风格)映射到 iconfont 的 unicode(形如 "\ue6aa")
|
||||
// 小程序不支持通过 :before { content } 渲染,因此必须直接输出 unicode 字符
|
||||
getFontGlyph(name) {
|
||||
const map = {
|
||||
// 基础高频(来自 static/iconfont.css 的 content 值)
|
||||
'wallet': '\ue6c8',
|
||||
'gift': '\ue6c9',
|
||||
'user': '\ue6b9',
|
||||
'search': '\ue6aa',
|
||||
'share': '\ue6ab',
|
||||
'home': '\ue694',
|
||||
'lock': '\ue699',
|
||||
'camera': '\ue671',
|
||||
'warning': '\ue6bd',
|
||||
|
||||
// 箭头/展开
|
||||
'chevron-left': '\ue6c1',
|
||||
'chevron-right': '\ue6c6',
|
||||
'chevron-down': '\ue6c4',
|
||||
'chevron-up': '\ue6c2',
|
||||
'arrow-up-right': '\ue6c2',
|
||||
|
||||
// 交互/状态
|
||||
'x': '\ue6c3',
|
||||
'check': '\ue6c7',
|
||||
'plus': '\ue664',
|
||||
'trash-2': '\ue66a',
|
||||
'pencil': '\ue685',
|
||||
'zap': '\ue75c',
|
||||
'info': '\ue69c',
|
||||
|
||||
// 语义近似映射(iconfont 不一定有同名)
|
||||
'map-pin': '\ue6a8',
|
||||
'message-circle': '\ue678',
|
||||
'smartphone': '\ue6a0',
|
||||
'refresh-cw': '\ue6a4',
|
||||
'shield': '\ue6ad',
|
||||
'star': '\ue689',
|
||||
'heart': '\ue68e',
|
||||
|
||||
// 其他:若 iconfont 里不存在,则继续走 SVG 兜底
|
||||
'book-open': '\ue993',
|
||||
'bar-chart': '\ue672',
|
||||
'clock': '\ue6b5',
|
||||
}
|
||||
return map[name] || ''
|
||||
},
|
||||
|
||||
// SVG 图标数据映射(Lucide 风格,替换原 emoji)
|
||||
getSvgPath(name) {
|
||||
const s = '<svg viewBox="0 0 24 24" fill="none" stroke="COLOR" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
||||
const svgMap = {
|
||||
'share': s + '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
|
||||
'arrow-up-right': s + '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/></svg>',
|
||||
'chevron-left': s + '<polyline points="15 18 9 12 15 6"/></svg>',
|
||||
'search': s + '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||
'heart': s + '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
||||
'user': s + '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||
'smartphone': s + '<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>',
|
||||
'map-pin': s + '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>',
|
||||
'home': s + '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
|
||||
'star': s + '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
|
||||
'message-circle': s + '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
|
||||
'package': s + '<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
|
||||
'book-open': s + '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
|
||||
'lightbulb': s + '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>',
|
||||
'handshake': s + '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a1 1 0 0 0-1.4 1.4l.88.88"/><path d="M15 9 9.03 9"/><path d="m14 14-2.5-2.5"/><path d="m18 15-3-3"/><path d="m15 12-3-3"/><path d="m9 12 2 2"/></svg>',
|
||||
'rocket': s + '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
|
||||
'trophy': s + '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>',
|
||||
'refresh-cw': s + '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
|
||||
'shield': s + '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||||
'wallet': s + '<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/></svg>',
|
||||
'wrench': s + '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
|
||||
'camera': s + '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>',
|
||||
'phone': s + '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>',
|
||||
'clipboard': s + '<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="6" width="8" height="12" rx="1"/></svg>',
|
||||
'megaphone': s + '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>',
|
||||
'image': s + '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>',
|
||||
'gift': s + '<polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>',
|
||||
'lock': s + '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
|
||||
'lock-open': s + '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>',
|
||||
'sparkles': s + '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>',
|
||||
'save': s + '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
|
||||
'globe': s + '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||||
'users': s + '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
||||
'gamepad': s + '<line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg>',
|
||||
'check': s + '<polyline points="20 6 9 17 4 12"/></svg>',
|
||||
'trash-2': s + '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
|
||||
'clock': s + '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
'plus': s + '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
||||
'briefcase': s + '<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>',
|
||||
'target': s + '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
|
||||
'rotate-ccw': s + '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
|
||||
'corner-down-left': s + '<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg>',
|
||||
'folder': s + '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/></svg>',
|
||||
'bar-chart': s + '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>',
|
||||
'link': s + '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
|
||||
}
|
||||
|
||||
return svgMap[name] || ''
|
||||
},
|
||||
|
||||
// 更新图标
|
||||
updateIcon() {
|
||||
const { name, color } = this.data
|
||||
const fontGlyph = this.getFontGlyph(name)
|
||||
let svgString = this.getSvgPath(name)
|
||||
|
||||
// 若 iconfont 存在映射,则优先用字体图标;否则走 SVG
|
||||
if (fontGlyph) {
|
||||
this.setData({ fontGlyph, svgData: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (svgString) {
|
||||
// 替换颜色占位符
|
||||
svgString = svgString.replace(/COLOR/g, color)
|
||||
@@ -71,11 +163,13 @@ Component({
|
||||
const svgData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`
|
||||
|
||||
this.setData({
|
||||
svgData: svgData
|
||||
svgData: svgData,
|
||||
fontGlyph: ''
|
||||
})
|
||||
} else {
|
||||
this.setData({
|
||||
svgData: ''
|
||||
svgData: '',
|
||||
fontGlyph: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<!-- components/icon/icon.wxml -->
|
||||
<view class="icon icon-{{name}} {{customClass}}" style="width: {{size}}rpx; height: {{size}}rpx; {{customStyle}}">
|
||||
<image wx:if="{{svgData}}" class="icon-image" src="{{svgData}}" mode="aspectFit" style="width: {{size}}rpx; height: {{size}}rpx;" />
|
||||
<!-- 优先 iconfont,其次 SVG dataUrl,最后兜底 name 文本 -->
|
||||
<text
|
||||
wx:if="{{fontGlyph}}"
|
||||
class="iconfont"
|
||||
style="font-size: {{size}}rpx; line-height: {{size}}rpx; color: {{color}};"
|
||||
>{{fontGlyph}}</text>
|
||||
<image wx:elif="{{svgData}}" class="icon-image" src="{{svgData}}" mode="aspectFit" style="width: {{size}}rpx; height: {{size}}rpx;" />
|
||||
<text wx:else class="icon-text">{{name}}</text>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* components/icon/icon.wxss */
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -6,6 +7,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -67,10 +67,7 @@ Component({
|
||||
console.log('[TabBar] 开始加载功能配置...')
|
||||
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config')
|
||||
|
||||
// app.request 的第一个参数是 url 字符串,第二个参数是 options 对象
|
||||
const res = await app.request('/api/miniprogram/config', {
|
||||
method: 'GET'
|
||||
})
|
||||
const res = await app.getConfig()
|
||||
|
||||
|
||||
// 兼容两种返回格式
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--关于作者-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">←</view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">关于作者</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- 亮点标签 -->
|
||||
<view class="highlights" wx:if="{{author.highlights}}">
|
||||
<view class="highlight-tag" wx:for="{{author.highlights}}" wx:key="*this">
|
||||
<text class="tag-icon">✓</text>
|
||||
<icon name="check" size="24" color="#34C759" customClass="tag-icon"></icon>
|
||||
<text>{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<!-- 书籍信息 -->
|
||||
<view class="book-info-card" wx:if="{{bookInfo && !authorLoading}}">
|
||||
<text class="card-title">📚 {{bookInfo.title}}</text>
|
||||
<view class="card-title"><icon name="book-open" size="36" color="#00CED1" customClass="card-title-icon"></icon><text>{{bookInfo.title}}</text></view>
|
||||
<view class="book-stats">
|
||||
<view class="book-stat">
|
||||
<text class="book-stat-value">{{bookInfo.totalChapters}}</text>
|
||||
@@ -65,7 +65,7 @@
|
||||
<view class="contact-card" wx:if="{{!authorLoading}}">
|
||||
<text class="card-title">联系作者</text>
|
||||
<view class="contact-item">
|
||||
<text class="contact-icon">🎉</text>
|
||||
<icon name="sparkles" size="40" color="#00CED1" customClass="contact-icon"></icon>
|
||||
<view class="contact-info">
|
||||
<text class="contact-label">Soul派对房</text>
|
||||
<text class="contact-value">每天早上6-9点开播</text>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
.stat-value { font-size: 36rpx; font-weight: 700; color: #00CED1; display: block; }
|
||||
.stat-label { font-size: 22rpx; color: rgba(255,255,255,0.5); }
|
||||
.contact-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: block; margin-bottom: 24rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 12rpx; margin-bottom: 24rpx; }
|
||||
.card-title .card-title-icon { flex-shrink: 0; }
|
||||
.contact-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 16rpx; }
|
||||
.contact-item:last-child { margin-bottom: 0; }
|
||||
.contact-icon { font-size: 40rpx; }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">收货地址</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" wx:elif="{{addressList.length === 0}}">
|
||||
<text class="empty-icon">📍</text>
|
||||
<icon name="map-pin" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
|
||||
<text class="empty-text">暂无收货地址</text>
|
||||
<text class="empty-tip">点击下方按钮添加</text>
|
||||
</view>
|
||||
@@ -42,7 +42,7 @@
|
||||
bindtap="editAddress"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<text class="action-icon">✏️</text>
|
||||
<icon name="pencil" size="36" color="#00CED1" customClass="action-icon"></icon>
|
||||
<text class="action-text">编辑</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -50,7 +50,7 @@
|
||||
bindtap="deleteAddress"
|
||||
data-id="{{item.id}}"
|
||||
>
|
||||
<text class="action-icon">🗑️</text>
|
||||
<icon name="trash-2" size="36" color="#ff3b30" customClass="action-icon"></icon>
|
||||
<text class="action-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<!-- 新增按钮 -->
|
||||
<view class="add-btn" bindtap="addAddress">
|
||||
<text class="add-icon">➕</text>
|
||||
<icon name="plus" size="36" color="#00CED1" customClass="add-icon"></icon>
|
||||
<text class="add-text">新增收货地址</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">{{isEdit ? '编辑地址' : '新增地址'}}</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- 收货人 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-icon">👤</text>
|
||||
<icon name="user" size="36" color="#8e8e93" customClass="label-icon"></icon>
|
||||
<text class="label-text">收货人</text>
|
||||
</view>
|
||||
<input
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- 手机号 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-icon">📱</text>
|
||||
<icon name="smartphone" size="36" color="#8e8e93" customClass="label-icon"></icon>
|
||||
<text class="label-text">手机号</text>
|
||||
</view>
|
||||
<input
|
||||
@@ -47,7 +47,7 @@
|
||||
<!-- 地区选择 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-icon">📍</text>
|
||||
<icon name="map-pin" size="36" color="#8e8e93" customClass="label-icon"></icon>
|
||||
<text class="label-text">所在地区</text>
|
||||
</view>
|
||||
<picker
|
||||
@@ -65,7 +65,7 @@
|
||||
<!-- 详细地址 -->
|
||||
<view class="form-item">
|
||||
<view class="form-label">
|
||||
<text class="label-icon">🏠</text>
|
||||
<icon name="home" size="36" color="#8e8e93" customClass="label-icon"></icon>
|
||||
<text class="label-text">详细地址</text>
|
||||
</view>
|
||||
<textarea
|
||||
@@ -82,7 +82,7 @@
|
||||
<!-- 设为默认 -->
|
||||
<view class="form-item form-switch">
|
||||
<view class="form-label">
|
||||
<text class="label-icon">⭐</text>
|
||||
<icon name="star" size="36" color="#8e8e93" customClass="label-icon"></icon>
|
||||
<text class="label-text">设为默认地址</text>
|
||||
</view>
|
||||
<switch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--用户协议页 - 审核要求可点击查看-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">←</view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">用户协议</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--Soul创业派对 - 头像昵称引导页,仅头像+昵称-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">完善资料</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
@@ -10,7 +10,7 @@
|
||||
<view class="content">
|
||||
<!-- 引导文案 -->
|
||||
<view class="guide-card">
|
||||
<text class="guide-icon">👋</text>
|
||||
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
|
||||
<text class="guide-title">完善头像和昵称</text>
|
||||
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
|
||||
</view>
|
||||
@@ -22,7 +22,7 @@
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera">📷</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
<text class="avatar-change">点击更换头像</text>
|
||||
</view>
|
||||
@@ -50,14 +50,14 @@
|
||||
|
||||
<view class="link-row" bindtap="goToFullProfile">
|
||||
<text class="link-text">完善更多资料</text>
|
||||
<text class="link-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="#00CED1" customClass="link-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 头像弹窗:使用微信头像 -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<text class="avatar-modal-title">使用微信头像</text>
|
||||
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
|
||||
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
|
||||
|
||||
@@ -20,18 +20,31 @@ Page({
|
||||
isVip: false,
|
||||
purchasedSections: [],
|
||||
|
||||
// 书籍数据:以后台内容管理为准,仅用接口 /api/miniprogram/book/all-chapters 返回的数据
|
||||
// 懒加载:篇章列表(不含章节详情),展开时再请求 chapters-by-part
|
||||
totalSections: 0,
|
||||
bookData: [],
|
||||
|
||||
// 展开状态:默认不展开任何篇章,直接显示目录
|
||||
// 展开状态
|
||||
expandedPart: null,
|
||||
|
||||
// 已加载的篇章章节缓存 { partId: chapters }
|
||||
_loadedChapters: {},
|
||||
|
||||
// 固定模块 id -> mid(序言/尾声/附录,供 goToRead 传 mid)
|
||||
fixedSectionsMap: {},
|
||||
|
||||
// 附录
|
||||
appendixList: [],
|
||||
appendixList: [
|
||||
{ id: 'appendix-1', title: '附录1|Soul派对房精选对话' },
|
||||
{ id: 'appendix-2', title: '附录2|创业者自检清单' },
|
||||
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源' }
|
||||
],
|
||||
|
||||
// 每日新增章节
|
||||
dailyChapters: []
|
||||
// book/parts 加载中
|
||||
partsLoading: true,
|
||||
|
||||
// 功能配置(搜索开关)
|
||||
searchEnabled: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -42,74 +55,92 @@ Page({
|
||||
})
|
||||
this.updateUserStatus()
|
||||
this.loadVipStatus()
|
||||
this.loadChaptersOnce()
|
||||
this.loadParts()
|
||||
this.loadFeatureConfig()
|
||||
},
|
||||
|
||||
// 固定模块(序言、尾声、附录)不参与中间篇章
|
||||
_isFixedPart(pt) {
|
||||
if (!pt) return false
|
||||
const p = String(pt).toLowerCase().replace(/[_\s||]/g, '')
|
||||
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
|
||||
},
|
||||
|
||||
// 一次请求拉取全量目录,以后台内容管理为准;同时更新 totalSections / bookData / dailyChapters
|
||||
async loadChaptersOnce() {
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const rows = (res && res.data) || (res && res.chapters) || []
|
||||
|
||||
// 无数据时清空目录,避免展示旧数据
|
||||
if (rows.length === 0) {
|
||||
app.globalData.bookData = []
|
||||
wx.setStorageSync('bookData', [])
|
||||
this.setData({
|
||||
bookData: [],
|
||||
totalSections: 0,
|
||||
dailyChapters: [],
|
||||
expandedPart: null
|
||||
})
|
||||
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
|
||||
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
|
||||
return
|
||||
}
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
this.setData({ searchEnabled })
|
||||
} catch (e) {
|
||||
this.setData({ searchEnabled: true })
|
||||
}
|
||||
},
|
||||
|
||||
const totalSections = res.total ?? rows.length
|
||||
app.globalData.bookData = rows
|
||||
app.globalData.totalSections = totalSections
|
||||
wx.setStorageSync('bookData', rows)
|
||||
|
||||
// bookData:过滤序言/尾声/附录,按 part 聚合,篇章顺序按 sort_order 与后台一致(含「2026每日派对干货」等)
|
||||
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
|
||||
const partMap = new Map()
|
||||
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections(book/parts,不再用 all-chapters)
|
||||
async loadParts() {
|
||||
this.setData({ partsLoading: true })
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
|
||||
let parts = []
|
||||
let totalSections = 0
|
||||
let fixedSections = []
|
||||
if (res?.success && Array.isArray(res.parts) && res.parts.length > 0) {
|
||||
parts = res.parts
|
||||
totalSections = res.totalSections ?? 0
|
||||
fixedSections = res.fixedSections || []
|
||||
}
|
||||
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||
filtered.forEach((r) => {
|
||||
const pid = r.partId || r.part_id || 'part-1'
|
||||
const fixedMap = {}
|
||||
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
|
||||
const appendixList = [
|
||||
{ id: 'appendix-1', title: '附录1|Soul派对房精选对话', mid: fixedMap['appendix-1'] },
|
||||
{ id: 'appendix-2', title: '附录2|创业者自检清单', mid: fixedMap['appendix-2'] },
|
||||
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源', mid: fixedMap['appendix-3'] }
|
||||
]
|
||||
const bookData = parts.map((p, idx) => ({
|
||||
id: p.id,
|
||||
number: numbers[idx] || String(idx + 1),
|
||||
title: p.title,
|
||||
subtitle: p.subtitle || '',
|
||||
chapterCount: p.chapterCount || 0,
|
||||
chapters: [] // 展开时懒加载
|
||||
}))
|
||||
app.globalData.totalSections = totalSections
|
||||
this.setData({
|
||||
bookData,
|
||||
totalSections,
|
||||
fixedSectionsMap: fixedMap,
|
||||
appendixList,
|
||||
_loadedChapters: {},
|
||||
partsLoading: false
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[Chapters] 加载篇章失败:', e)
|
||||
this.setData({ bookData: [], totalSections: 0, partsLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 展开时懒加载该篇章的章节(含 mid,供阅读页 by-mid 请求)
|
||||
async loadChaptersByPart(partId) {
|
||||
if (this.data._loadedChapters[partId]) return
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/book/chapters-by-part?partId=${encodeURIComponent(partId)}`,
|
||||
silent: true
|
||||
})
|
||||
const rows = (res && res.data) || []
|
||||
const chMap = new Map()
|
||||
rows.forEach(r => {
|
||||
const cid = r.chapterId || r.chapter_id || 'chapter-1'
|
||||
const sortOrder = r.sectionOrder ?? r.sort_order ?? 999999
|
||||
if (!partMap.has(pid)) {
|
||||
const partIdx = partMap.size
|
||||
partMap.set(pid, {
|
||||
id: pid,
|
||||
number: numbers[partIdx] || String(partIdx + 1),
|
||||
title: r.partTitle || r.part_title || '未分类',
|
||||
subtitle: r.chapterTitle || r.chapter_title || '',
|
||||
chapters: new Map(),
|
||||
minSortOrder: sortOrder
|
||||
})
|
||||
}
|
||||
const part = partMap.get(pid)
|
||||
if (sortOrder < part.minSortOrder) part.minSortOrder = sortOrder
|
||||
if (!part.chapters.has(cid)) {
|
||||
part.chapters.set(cid, {
|
||||
if (!chMap.has(cid)) {
|
||||
chMap.set(cid, {
|
||||
id: cid,
|
||||
title: r.chapterTitle || r.chapter_title || '未分类',
|
||||
sections: []
|
||||
})
|
||||
}
|
||||
const ch = part.chapters.get(cid)
|
||||
const isPremium =
|
||||
r.editionPremium === true ||
|
||||
r.edition_premium === true ||
|
||||
r.edition_premium === 1 ||
|
||||
r.edition_premium === '1'
|
||||
const ch = chMap.get(cid)
|
||||
const isPremium = r.editionPremium === true || r.edition_premium === true || r.edition_premium === 1 || r.edition_premium === '1'
|
||||
ch.sections.push({
|
||||
id: r.id,
|
||||
mid: r.mid ?? r.MID ?? 0,
|
||||
@@ -120,57 +151,29 @@ Page({
|
||||
isPremium
|
||||
})
|
||||
})
|
||||
const partList = Array.from(partMap.values())
|
||||
partList.sort((a, b) => (a.minSortOrder ?? 999999) - (b.minSortOrder ?? 999999))
|
||||
const bookData = partList.map((p, idx) => ({
|
||||
id: p.id,
|
||||
number: numbers[idx] || String(idx + 1),
|
||||
title: p.title,
|
||||
subtitle: p.subtitle,
|
||||
chapters: Array.from(p.chapters.values())
|
||||
}))
|
||||
|
||||
const baseSort = 62
|
||||
const appendixList = rows
|
||||
.filter(r => {
|
||||
const partTitle = String(r.partTitle || r.part_title || '')
|
||||
return partTitle.includes('附录')
|
||||
})
|
||||
.sort((a, b) => (a.sort_order ?? a.sectionOrder ?? 999999) - (b.sort_order ?? b.sectionOrder ?? 999999))
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
title: c.section_title || c.sectionTitle || c.title || c.chapterTitle || '附录'
|
||||
}))
|
||||
const daily = rows
|
||||
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
|
||||
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
|
||||
.slice(0, 20)
|
||||
.map(c => {
|
||||
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
||||
return {
|
||||
id: c.id,
|
||||
mid: c.mid ?? c.MID ?? 0,
|
||||
title: c.section_title || c.title || c.sectionTitle,
|
||||
price: c.price ?? 1,
|
||||
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
})
|
||||
|
||||
this.setData({
|
||||
bookData,
|
||||
totalSections,
|
||||
appendixList,
|
||||
dailyChapters: daily,
|
||||
expandedPart: this.data.expandedPart
|
||||
const chapters = Array.from(chMap.values())
|
||||
const loaded = { ...this.data._loadedChapters, [partId]: chapters }
|
||||
const bookData = this.data.bookData.map(p =>
|
||||
p.id === partId ? { ...p, chapters } : p
|
||||
)
|
||||
const bookDataFlat = app.globalData.bookData || []
|
||||
rows.forEach(r => {
|
||||
const idx = bookDataFlat.findIndex(c => c.id === r.id)
|
||||
if (idx >= 0) bookDataFlat[idx] = { ...bookDataFlat[idx], ...r }
|
||||
else bookDataFlat.push(r)
|
||||
})
|
||||
app.globalData.bookData = bookDataFlat
|
||||
wx.setStorage({ key: 'bookData', data: bookDataFlat }) // 异步写入,避免阻塞主线程
|
||||
this.setData({ bookData, _loadedChapters: loaded })
|
||||
} catch (e) {
|
||||
console.log('[Chapters] 加载目录失败:', e)
|
||||
this.setData({ bookData: [], totalSections: 0 })
|
||||
console.log('[Chapters] 加载章节失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadChaptersOnce().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh())
|
||||
this.loadParts()
|
||||
.then(() => wx.stopPullDownRefresh())
|
||||
.catch(() => wx.stopPullDownRefresh())
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -213,19 +216,21 @@ Page({
|
||||
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
|
||||
},
|
||||
|
||||
// 切换展开状态
|
||||
togglePart(e) {
|
||||
// 切换展开状态,展开时懒加载该篇章章节
|
||||
async togglePart(e) {
|
||||
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
|
||||
const partId = e.currentTarget.dataset.id
|
||||
const isExpanding = this.data.expandedPart !== partId
|
||||
this.setData({
|
||||
expandedPart: this.data.expandedPart === partId ? null : partId
|
||||
expandedPart: isExpanding ? partId : null
|
||||
})
|
||||
if (isExpanding) await this.loadChaptersByPart(partId)
|
||||
},
|
||||
|
||||
// 跳转到阅读页(优先传 mid,与分享逻辑一致)
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
trackClick('chapters', 'card_click', id || '章节')
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
@@ -245,6 +250,7 @@ Page({
|
||||
|
||||
// 跳转到搜索页
|
||||
goToSearch() {
|
||||
if (!this.data.searchEnabled) return
|
||||
trackClick('chapters', 'nav_click', '搜索')
|
||||
wx.navigateTo({ url: '/pages/search/search' })
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-left">
|
||||
<view class="search-btn" bindtap="goToSearch">
|
||||
<text class="search-icon">🔍</text>
|
||||
<view class="search-btn" wx:if="{{searchEnabled}}" bindtap="goToSearch">
|
||||
<icon name="search" size="32" color="rgba(255,255,255,0.6)" customClass="search-icon"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-title brand-color">目录</view>
|
||||
@@ -17,10 +17,27 @@
|
||||
<!-- 导航栏占位 -->
|
||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<!-- 目录骨架屏:加载中时展示 -->
|
||||
<view class="parts-skeleton" wx:if="{{partsLoading}}">
|
||||
<view class="skeleton-book-card">
|
||||
<view class="skeleton-book-icon"></view>
|
||||
<view class="skeleton-book-info">
|
||||
<view class="skeleton-line skeleton-title"></view>
|
||||
<view class="skeleton-line skeleton-subtitle"></view>
|
||||
</view>
|
||||
<view class="skeleton-count"></view>
|
||||
</view>
|
||||
<view class="skeleton-part-list">
|
||||
<view class="skeleton-part-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this">
|
||||
<view class="skeleton-part-header"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 书籍信息卡 -->
|
||||
<view class="book-info-card card-gradient">
|
||||
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}">
|
||||
<view class="book-icon">
|
||||
<view class="book-icon-inner">📚</view>
|
||||
<view class="book-icon-inner"><icon name="book-open" size="56" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
<view class="book-info">
|
||||
<text class="book-title">一场SOUL的创业实验场</text>
|
||||
@@ -33,16 +50,16 @@
|
||||
</view>
|
||||
|
||||
<!-- 目录内容 -->
|
||||
<view class="chapters-content">
|
||||
<!-- 序言 -->
|
||||
<view class="chapter-item" bindtap="goToRead" data-id="preface">
|
||||
<view class="chapters-content" wx:if="{{!partsLoading}}">
|
||||
<!-- 序言(优先传 mid,阅读页用 by-mid 请求) -->
|
||||
<view class="chapter-item" bindtap="goToRead" data-id="preface" data-mid="{{fixedSectionsMap.preface}}">
|
||||
<view class="item-left">
|
||||
<view class="item-icon icon-brand">📖</view>
|
||||
<view class="item-icon icon-brand"><icon name="book-open" size="36" color="#00CED1"></icon></view>
|
||||
<text class="item-title">序言|为什么我每天早上6点在Soul开播?</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<text class="tag tag-free">免费</text>
|
||||
<text class="item-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.4)" customClass="item-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -59,30 +76,33 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="part-right">
|
||||
<text class="part-count">{{item.chapters.length}}章</text>
|
||||
<text class="part-arrow {{expandedPart === item.id ? 'arrow-down' : ''}}">→</text>
|
||||
<text class="part-count">{{item.chapters.length || item.chapterCount}}章</text>
|
||||
<icon name="{{expandedPart === item.id ? 'chevron-down' : 'chevron-right'}}" size="28" color="rgba(255,255,255,0.4)" customClass="part-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 章节列表 - 展开时显示 -->
|
||||
<!-- 章节列表 - 展开时显示,懒加载 -->
|
||||
<block wx:if="{{expandedPart === item.id}}">
|
||||
<view class="chapters-list">
|
||||
<view wx:if="{{item.chapters.length === 0}}" class="chapters-loading">加载中...</view>
|
||||
<block wx:for="{{item.chapters}}" wx:key="id" wx:for-item="chapter">
|
||||
<view class="chapter-header">{{chapter.title}}</view>
|
||||
<view class="section-list">
|
||||
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
|
||||
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
|
||||
<view class="section-left">
|
||||
<text class="section-lock {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
|
||||
<view class="section-lock-wrap">
|
||||
<icon wx:if="{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" name="lock-open" size="24" color="#00CED1" customClass="section-lock lock-open"></icon>
|
||||
<icon wx:else name="lock" size="24" color="rgba(255,255,255,0.3)" customClass="section-lock lock-closed"></icon>
|
||||
</view>
|
||||
<text class="section-title {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
|
||||
<text wx:if="{{section.isNew}}" class="tag tag-new">NEW</text>
|
||||
<text wx:if="{{section.isPremium}}" class="tag tag-vip">增值</text>
|
||||
</view>
|
||||
<view class="section-right">
|
||||
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>
|
||||
<text wx:elif="{{isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" class="tag tag-purchased">已解锁</text>
|
||||
<text wx:else class="section-price">¥{{section.price}}</text>
|
||||
<text class="section-arrow">›</text>
|
||||
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.3)" customClass="section-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
@@ -93,15 +113,15 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 尾声 -->
|
||||
<view class="chapter-item" bindtap="goToRead" data-id="epilogue">
|
||||
<!-- 尾声(优先传 mid) -->
|
||||
<view class="chapter-item" bindtap="goToRead" data-id="epilogue" data-mid="{{fixedSectionsMap.epilogue}}">
|
||||
<view class="item-left">
|
||||
<view class="item-icon icon-brand">📖</view>
|
||||
<view class="item-icon icon-brand"><icon name="book-open" size="36" color="#00CED1"></icon></view>
|
||||
<text class="item-title">尾声|这本书的真实目的</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<text class="tag tag-free">免费</text>
|
||||
<text class="item-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.4)" customClass="item-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -115,9 +135,10 @@
|
||||
wx:key="id"
|
||||
bindtap="goToRead"
|
||||
data-id="{{item.id}}"
|
||||
data-mid="{{item.mid}}"
|
||||
>
|
||||
<text class="appendix-text">{{item.title}}</text>
|
||||
<text class="appendix-arrow">→</text>
|
||||
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.3)" customClass="appendix-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -75,6 +75,77 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ===== 目录骨架屏 ===== */
|
||||
.parts-skeleton {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-book-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%);
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-book-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-book-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 32rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-title { width: 70%; }
|
||||
.skeleton-subtitle { width: 50%; }
|
||||
|
||||
.skeleton-count {
|
||||
width: 80rpx;
|
||||
height: 64rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-part-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-part-item .skeleton-part-header {
|
||||
height: 100rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ===== 书籍信息卡 ===== */
|
||||
.book-info-card {
|
||||
display: flex;
|
||||
@@ -339,6 +410,12 @@
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.chapters-loading {
|
||||
padding: 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.chapter-group {
|
||||
background: rgba(28, 28, 30, 0.5);
|
||||
border-radius: 16rpx;
|
||||
@@ -394,6 +471,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-lock-wrap {
|
||||
min-width: 32rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lock-open {
|
||||
color: #00CED1;
|
||||
}
|
||||
@@ -492,21 +577,6 @@
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ===== 每日新增章节 ===== */
|
||||
.daily-section { margin: 20rpx 0; padding: 24rpx; background: rgba(255,215,0,0.04); border: 1rpx solid rgba(255,215,0,0.12); border-radius: 16rpx; }
|
||||
.daily-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.daily-title { font-size: 30rpx; font-weight: 600; color: #FFD700; }
|
||||
.daily-badge { font-size: 22rpx; background: #FFD700; color: #000; padding: 2rpx 12rpx; border-radius: 20rpx; font-weight: bold; }
|
||||
.daily-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.daily-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx; background: rgba(255,255,255,0.03); border-radius: 12rpx; }
|
||||
.daily-left { display: flex; align-items: center; gap: 10rpx; flex: 1; min-width: 0; }
|
||||
.daily-new-tag { font-size: 18rpx; background: #FF4444; color: #fff; padding: 2rpx 8rpx; border-radius: 6rpx; font-weight: bold; flex-shrink: 0; }
|
||||
.daily-item-title { font-size: 26rpx; color: rgba(255,255,255,0.85); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.daily-right { display: flex; align-items: center; gap: 12rpx; flex-shrink: 0; }
|
||||
.daily-price { font-size: 26rpx; color: #FFD700; font-weight: 600; }
|
||||
.daily-date { font-size: 20rpx; color: rgba(255,255,255,0.35); }
|
||||
.daily-note { display: block; font-size: 22rpx; color: rgba(255,215,0,0.5); margin-top: 12rpx; text-align: center; }
|
||||
|
||||
/* ===== 底部留白 ===== */
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Soul创业派对 - 代付详情页
|
||||
* 好友打开后看到订单信息,点击「帮他付款」完成代付
|
||||
* 改造后:发起人支付,好友领取。支持单页模式引导、登录检测。
|
||||
*/
|
||||
const app = getApp()
|
||||
|
||||
@@ -8,34 +8,81 @@ Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
requestSn: '',
|
||||
sectionId: '',
|
||||
detail: null,
|
||||
loading: true,
|
||||
paying: false,
|
||||
isInitiator: false // 是否发起人,发起人看到「分享给好友」UI,好友看到「帮他付款」
|
||||
redeeming: false,
|
||||
isInitiator: false,
|
||||
requesterMsg: '',
|
||||
amountDisplay: '0.00',
|
||||
isSinglePageMode: false,
|
||||
showLoginModal: false,
|
||||
agreeProtocol: false,
|
||||
// 创建态
|
||||
isCreateMode: false,
|
||||
giftQuantity: 1,
|
||||
unitPrice: 0
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
const requestSn = (options.requestSn || '').trim()
|
||||
if (!requestSn) {
|
||||
wx.showToast({ title: '代付链接无效', icon: 'none' })
|
||||
const sectionId = (options.sectionId || '').trim()
|
||||
const isSinglePage = (wx.getSystemInfoSync?.()?.mode === 'singlePage') || app.globalData.isSinglePageMode
|
||||
this.setData({ requestSn, sectionId, isSinglePageMode: !!isSinglePage })
|
||||
if (requestSn || sectionId) {
|
||||
this.loadDetail()
|
||||
} else {
|
||||
wx.showToast({ title: '链接无效', icon: 'none' })
|
||||
setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 1500)
|
||||
return
|
||||
}
|
||||
this.setData({ requestSn })
|
||||
this.loadDetail()
|
||||
},
|
||||
|
||||
async loadDetail() {
|
||||
const { requestSn } = this.data
|
||||
if (!requestSn) return
|
||||
const { requestSn, sectionId } = this.data
|
||||
this.setData({ loading: true })
|
||||
const userId = app.globalData.userInfo?.id || ''
|
||||
let url = ''
|
||||
if (requestSn) {
|
||||
url = `/api/miniprogram/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}${userId ? '&userId=' + encodeURIComponent(userId) : ''}`
|
||||
} else if (sectionId && userId) {
|
||||
url = `/api/miniprogram/gift-pay/detail?sectionId=${encodeURIComponent(sectionId)}&userId=${encodeURIComponent(userId)}`
|
||||
} else if (sectionId) {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await app.request(`/api/miniprogram/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}`)
|
||||
const res = await app.request(url)
|
||||
if (res && res.success) {
|
||||
const myId = app.globalData.userInfo?.id || ''
|
||||
const isInitiator = !!myId && res.initiatorUserId === myId
|
||||
this.setData({ detail: res, loading: false, isInitiator })
|
||||
const isCreateMode = res.mode === 'create' || res.action === 'create'
|
||||
const isInitiator = res.isInitiator === true
|
||||
let requesterMsg = ''
|
||||
let amountDisplay = '0.00'
|
||||
if (isCreateMode) {
|
||||
requesterMsg = '输入发放数量,支付后分享给好友免费领取'
|
||||
amountDisplay = (res.unitPrice != null ? Number(res.unitPrice) * (this.data.giftQuantity || 1) : 0).toFixed(2)
|
||||
} else {
|
||||
requesterMsg = isInitiator
|
||||
? (res.action === 'pay' ? '支付后分享给好友,好友打开即可免费领取。' : '分享给好友,好友打开即可免费领取。')
|
||||
: res.initiatorMsg || `" 请帮我代付「${res.sectionTitle || res.description || '该商品'}」,非常感谢! "`
|
||||
amountDisplay = (res.amount != null && res.amount !== '') ? Number(res.amount).toFixed(2) : '0.00'
|
||||
}
|
||||
this.setData({
|
||||
detail: res,
|
||||
loading: false,
|
||||
isInitiator,
|
||||
isCreateMode,
|
||||
requesterMsg,
|
||||
amountDisplay,
|
||||
unitPrice: res.unitPrice != null ? res.unitPrice : 0
|
||||
})
|
||||
if (isCreateMode) this._updateAmountDisplay()
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: res?.error || '加载失败', icon: 'none' })
|
||||
@@ -46,30 +93,82 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async doPay() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => wx.switchTab({ url: '/pages/my/my' }), 1500)
|
||||
onGiftQuantityInput(e) {
|
||||
const raw = (e.detail.value || '').trim()
|
||||
const v = parseInt(raw, 10)
|
||||
this.setData({ giftQuantity: isNaN(v) ? (raw === '' ? '' : this.data.giftQuantity) : v })
|
||||
this._updateAmountDisplay()
|
||||
},
|
||||
_updateAmountDisplay() {
|
||||
const { unitPrice, giftQuantity } = this.data
|
||||
const q = Math.max(0, parseInt(giftQuantity, 10) || 0)
|
||||
const amount = (unitPrice || 0) * q
|
||||
this.setData({ amountDisplay: amount.toFixed(2) })
|
||||
},
|
||||
|
||||
// 发起人支付(改造后:我帮别人付款)
|
||||
async doInitiatorPay() {
|
||||
if (this.data.isSinglePageMode) {
|
||||
wx.showModal({
|
||||
title: '朋友圈单页',
|
||||
content: '当前为朋友圈单页,无法支付。请点击底部「前往小程序」进入完整版后再支付。',
|
||||
showCancel: false
|
||||
})
|
||||
return
|
||||
}
|
||||
const openId = app.globalData.openId || ''
|
||||
const userId = app.globalData.userInfo?.id || ''
|
||||
if (!userId) {
|
||||
wx.showToast({ title: '请先登录后再支付', icon: 'none' })
|
||||
return
|
||||
}
|
||||
let openId = app.globalData.openId || wx.getStorageSync('openId')
|
||||
if (!openId) {
|
||||
wx.showToast({ title: '请先完成微信授权', icon: 'none' })
|
||||
wx.showLoading({ title: '获取支付凭证...', mask: true })
|
||||
openId = await app.getOpenId()
|
||||
wx.hideLoading()
|
||||
}
|
||||
if (!openId) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const { requestSn, detail } = this.data
|
||||
if (!requestSn || !detail) return
|
||||
let { requestSn, sectionId, detail, giftQuantity, isCreateMode } = this.data
|
||||
if (!requestSn && isCreateMode && sectionId) {
|
||||
const q = parseInt(giftQuantity, 10)
|
||||
if (isNaN(q) || q !== Math.floor(q) || q < 1) {
|
||||
wx.showToast({ title: '发放份数须为正整数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const quantity = q
|
||||
wx.showLoading({ title: '创建中...', mask: true })
|
||||
try {
|
||||
const createRes = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/create',
|
||||
method: 'POST',
|
||||
data: { userId, productType: 'section', productId: sectionId, quantity }
|
||||
})
|
||||
if (!createRes?.success || !createRes.requestSn) {
|
||||
throw new Error(createRes?.error || '创建失败')
|
||||
}
|
||||
requestSn = createRes.requestSn
|
||||
this.setData({ requestSn, isCreateMode: false })
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: e.message || e.error || '创建失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!requestSn) return
|
||||
|
||||
this.setData({ paying: true })
|
||||
wx.showLoading({ title: '创建订单中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/pay',
|
||||
url: '/api/miniprogram/gift-pay/initiator-pay',
|
||||
method: 'POST',
|
||||
data: {
|
||||
requestSn,
|
||||
openId,
|
||||
userId: app.globalData.userInfo?.id || ''
|
||||
userId
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
@@ -77,41 +176,174 @@ Page({
|
||||
throw new Error(res?.error || '创建订单失败')
|
||||
}
|
||||
const payParams = res.data.payParams
|
||||
payParams._orderSn = res.data.orderSn
|
||||
|
||||
const orderSn = res.data.orderSn
|
||||
// 与正常章节支付一致:只传 5 个必需参数,不传 appId 等多余字段
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
...payParams,
|
||||
signType: payParams.signType || 'MD5',
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: payParams.signType || 'RSA',
|
||||
paySign: payParams.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
wx.showToast({ title: '代付成功', icon: 'success' })
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
this.setData({ paying: false })
|
||||
setTimeout(() => {
|
||||
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
|
||||
}, 1500)
|
||||
// 主动同步订单状态(与 read 页一致)
|
||||
if (orderSn) {
|
||||
try {
|
||||
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
|
||||
} catch (e) {
|
||||
console.warn('[GiftPay] 主动同步订单失败:', e)
|
||||
}
|
||||
}
|
||||
this.loadDetail()
|
||||
} catch (e) {
|
||||
this.setData({ paying: false })
|
||||
const msg = e.message || e.error || e.errMsg || '支付失败'
|
||||
if (e.errMsg && e.errMsg.includes('cancel')) {
|
||||
wx.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else {
|
||||
wx.showToast({ title: e.message || e.error || '支付失败', icon: 'none' })
|
||||
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 好友领取(改造后:免费获得章节)
|
||||
async doRedeem() {
|
||||
if (this.data.isSinglePageMode) {
|
||||
wx.showModal({
|
||||
title: '朋友圈单页',
|
||||
content: '当前为朋友圈单页,无法登录领取。请点击底部「前往小程序」进入完整版后再领取。',
|
||||
showCancel: false
|
||||
})
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
this.setData({ showLoginModal: true, agreeProtocol: false })
|
||||
return
|
||||
}
|
||||
await this._doRedeem()
|
||||
},
|
||||
|
||||
async _doRedeem() {
|
||||
const { requestSn } = this.data
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!requestSn || !userId) return
|
||||
|
||||
this.setData({ redeeming: true })
|
||||
wx.showLoading({ title: '领取中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/redeem',
|
||||
method: 'POST',
|
||||
data: { requestSn, userId }
|
||||
})
|
||||
wx.hideLoading()
|
||||
this.setData({ redeeming: false })
|
||||
if (res && res.success) {
|
||||
wx.showToast({ title: '领取成功', icon: 'success' })
|
||||
const mid = res.sectionMid || res.sectionId
|
||||
const q = mid ? `mid=${mid}` : `id=${res.sectionId || ''}`
|
||||
setTimeout(() => {
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
}, 800)
|
||||
} else {
|
||||
wx.showToast({ title: res?.error || '领取失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
this.setData({ redeeming: false })
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: e.message || e.error || '领取失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
closeLoginModal() {
|
||||
this.setData({ showLoginModal: false })
|
||||
},
|
||||
toggleAgree() {
|
||||
this.setData({ agreeProtocol: !this.data.agreeProtocol })
|
||||
},
|
||||
async handleWechatLogin() {
|
||||
if (!this.data.agreeProtocol) {
|
||||
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await app.login()
|
||||
if (!result) return
|
||||
this.setData({ showLoginModal: false, agreeProtocol: false })
|
||||
await this._doRedeem()
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
async handlePhoneLogin(e) {
|
||||
if (!e.detail.code) return this.handleWechatLogin()
|
||||
try {
|
||||
const result = await app.loginWithPhone(e.detail.code)
|
||||
if (!result) return
|
||||
this.setData({ showLoginModal: false })
|
||||
await this._doRedeem()
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
stopPropagation() {},
|
||||
openUserProtocol() {
|
||||
wx.navigateTo({ url: '/pages/agreement/agreement' })
|
||||
},
|
||||
openPrivacy() {
|
||||
wx.navigateTo({ url: '/pages/privacy/privacy' })
|
||||
},
|
||||
|
||||
goBack() {
|
||||
app.goBackOrToHome()
|
||||
},
|
||||
|
||||
goToInitiatorProfile() {
|
||||
const { detail } = this.data
|
||||
if (!detail?.initiatorUserId) return
|
||||
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${detail.initiatorUserId}` })
|
||||
},
|
||||
|
||||
goToArticle() {
|
||||
const { detail } = this.data
|
||||
if (!detail || detail.productType !== 'section' || !detail.productId) return
|
||||
const mid = detail.productMid
|
||||
const q = mid ? `mid=${mid}` : `id=${detail.productId}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const { requestSn } = this.data
|
||||
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
|
||||
let path = '/pages/gift-pay/detail'
|
||||
if (requestSn) {
|
||||
path = `/pages/gift-pay/detail?requestSn=${requestSn}`
|
||||
if (ref) path += `&ref=${encodeURIComponent(ref)}`
|
||||
}
|
||||
return {
|
||||
title: '好友请你帮忙代付 - Soul创业派对',
|
||||
path: requestSn ? `/pages/gift-pay/detail?requestSn=${requestSn}` : '/pages/gift-pay/detail'
|
||||
title: '好友送你一篇好文 - Soul创业派对',
|
||||
path
|
||||
}
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const { requestSn } = this.data
|
||||
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
|
||||
let query = ''
|
||||
if (requestSn) {
|
||||
query = `requestSn=${requestSn}`
|
||||
if (ref) query += `&ref=${encodeURIComponent(ref)}`
|
||||
}
|
||||
return {
|
||||
title: '好友送你一篇好文 - Soul创业派对',
|
||||
query: query || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!-- Soul创业派对 - 代付详情页(美团式:发起人看到分享入口,好友看到帮他付款) -->
|
||||
<!-- Soul创业派对 - 代付详情页(改造后:发起人支付,好友领取) -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-arrow">←</text>
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-arrow"></icon>
|
||||
</view>
|
||||
<view class="nav-info">
|
||||
<text class="nav-title">{{isInitiator ? '找朋友代付' : '帮他付款'}}</text>
|
||||
<text class="nav-title">{{isInitiator ? '代付分享' : (detail.action === 'redeem' ? '免费领取' : '代付详情')}}</text>
|
||||
</view>
|
||||
<view class="nav-right-placeholder"></view>
|
||||
</view>
|
||||
@@ -14,56 +14,84 @@
|
||||
|
||||
<view class="content" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
|
||||
<block wx:if="{{loading}}">
|
||||
<view class="loading-box">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
<view class="skeleton-wrap">
|
||||
<view class="skeleton-hero">
|
||||
<view class="skeleton-hero-badge"></view>
|
||||
<view class="skeleton-hero-title"></view>
|
||||
<view class="skeleton-hero-desc"></view>
|
||||
<view class="skeleton-hero-amount"></view>
|
||||
</view>
|
||||
<view class="skeleton-card">
|
||||
<view class="skeleton-avatar"></view>
|
||||
<view class="skeleton-info">
|
||||
<view class="skeleton-line"></view>
|
||||
<view class="skeleton-line short"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{detail}}">
|
||||
<!-- 营销:章节标题+内容预览,吸引代付人 -->
|
||||
<view class="article-preview" wx:if="{{detail.sectionTitle || detail.contentPreview}}">
|
||||
<text class="article-title">{{detail.sectionTitle || detail.description || '代付商品'}}</text>
|
||||
<text class="article-content" wx:if="{{detail.contentPreview}}">{{detail.contentPreview}}</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<view class="card-badge">代付订单</view>
|
||||
<text class="initiator" wx:if="{{!isInitiator}}">{{detail.initiatorNickname || '好友'}} 请你帮忙付款</text>
|
||||
<text class="initiator" wx:else>分享给好友,好友帮你付款</text>
|
||||
</view>
|
||||
<view class="card-divider"></view>
|
||||
<view class="card-body">
|
||||
<view class="row product-row" wx:if="{{!detail.contentPreview}}">
|
||||
<text class="label">商品</text>
|
||||
<text class="value product-desc">{{detail.sectionTitle || detail.description || '-'}}</text>
|
||||
<!-- 产品 Hero 卡片(订单详情) -->
|
||||
<section class="hero-card">
|
||||
<view class="hero-glow"></view>
|
||||
<view class="hero-inner">
|
||||
<view class="hero-decor">
|
||||
<image class="hero-decor-img" src="/assets/icons/info.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="row amount-row">
|
||||
<text class="label">金额</text>
|
||||
<text class="amount">¥{{detail.amount ? detail.amount.toFixed(2) : '0.00'}}</text>
|
||||
<view class="hero-badge">订单详情</view>
|
||||
<text class="hero-title">{{detail.sectionTitle || detail.description || '代付商品'}}</text>
|
||||
<text class="hero-desc" wx:if="{{detail.contentPreview}}">{{detail.contentPreview}}</text>
|
||||
<view class="hero-footer">
|
||||
<view class="hero-amount-wrap" wx:if="{{!isCreateMode}}">
|
||||
<text class="hero-amount-label">{{detail.quantity > 1 ? '应付金额(' + detail.quantity + '份)' : '应付金额'}}</text>
|
||||
<view class="hero-amount-row">
|
||||
<text class="hero-currency">¥</text>
|
||||
<text class="hero-amount">{{amountDisplay}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-amount-wrap" wx:elif="{{isCreateMode}}">
|
||||
<text class="hero-amount-label">发放份数</text>
|
||||
<view class="gift-quantity-row">
|
||||
<input class="gift-quantity-input" type="number" value="{{giftQuantity}}" bindinput="onGiftQuantityInput" placeholder="请输入份数"/>
|
||||
<text class="hero-amount-label">份 × ¥{{detail.unitPrice || 0}}</text>
|
||||
</view>
|
||||
<view class="hero-amount-row">
|
||||
<text class="hero-currency">¥</text>
|
||||
<text class="hero-amount">{{amountDisplay}}</text>
|
||||
</view>
|
||||
<view class="create-tip">创建后无法退款</view>
|
||||
</view>
|
||||
<view class="hero-arrow-wrap" bindtap="goToArticle" wx:if="{{!isCreateMode && !isInitiator && detail.productType === 'section' && detail.productId}}">
|
||||
<image class="hero-arrow" src="/assets/icons/arrow-right.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="hero-arrow-wrap hero-arrow-placeholder" wx:elif="{{!isCreateMode && !isInitiator}}"></view>
|
||||
</view>
|
||||
</view>
|
||||
</section>
|
||||
|
||||
<!-- 发起人信息(发起人视角不展示) -->
|
||||
<section class="requester-card" wx:if="{{!isCreateMode && !isInitiator}}">
|
||||
<view class="requester-header" bindtap="goToInitiatorProfile">
|
||||
<view class="requester-avatar">
|
||||
<image wx:if="{{detail.initiatorAvatar}}" class="avatar-img" src="{{detail.initiatorAvatar}}" mode="aspectFill"/>
|
||||
<image wx:else class="avatar-img icon-avatar" src="/assets/icons/user.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="requester-info">
|
||||
<text class="requester-name">{{detail.initiatorNickname || '好友'}}</text>
|
||||
<text class="requester-label">发起代付请求</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="requester-msg-wrap">
|
||||
<view class="requester-msg-bar"></view>
|
||||
<text class="requester-msg">{{requesterMsg}}</text>
|
||||
</view>
|
||||
</section>
|
||||
|
||||
<!-- 安全徽章(发起人视角不展示) -->
|
||||
<view class="security-badge" wx:if="{{!isCreateMode && !isInitiator}}">
|
||||
<icon name="shield" size="40" color="#00CED1" customClass="security-icon"></icon>
|
||||
<text class="security-text">安全支付保障 · 资金由平台托管</text>
|
||||
</view>
|
||||
<!-- 发起人:分享给好友 -->
|
||||
<block wx:if="{{isInitiator}}">
|
||||
<view class="tips">
|
||||
<text class="tips-icon">💡</text>
|
||||
<text>分享给好友,好友打开后点击「帮他付款」即可为你代付</text>
|
||||
</view>
|
||||
<button class="pay-btn share-btn" open-type="share">
|
||||
<image class="btn-icon-img" src="/assets/icons/share.svg" mode="aspectFit"/>
|
||||
<text>分享给好友</text>
|
||||
</button>
|
||||
</block>
|
||||
<!-- 好友:帮他付款 -->
|
||||
<block wx:else>
|
||||
<view class="tips">
|
||||
<text class="tips-icon">✓</text>
|
||||
<text>付款后,{{detail.initiatorNickname || '好友'}}将获得对应权益</text>
|
||||
</view>
|
||||
<button class="pay-btn" bindtap="doPay" disabled="{{paying}}">
|
||||
{{paying ? '支付中...' : '帮他付款'}}
|
||||
</button>
|
||||
</block>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="empty">
|
||||
@@ -71,4 +99,73 @@
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 底部浮动操作栏 -->
|
||||
<view class="footer-bar" wx:if="{{detail && !loading}}">
|
||||
<view class="footer-bg"></view>
|
||||
<view class="footer-inner">
|
||||
<view class="footer-summary">
|
||||
<text class="footer-label">合计</text>
|
||||
<text class="footer-amount">
|
||||
<text class="footer-currency">¥</text>{{amountDisplay}}
|
||||
</text>
|
||||
</view>
|
||||
<!-- 单页模式:引导前往小程序 -->
|
||||
<view wx:if="{{isSinglePageMode}}" class="footer-tip-single">
|
||||
<text>请点击底部「前往小程序」进入完整版后再操作</text>
|
||||
</view>
|
||||
<!-- 发起人 创建态 或 action=pay:去支付 -->
|
||||
<button wx:elif="{{(isCreateMode || (isInitiator && detail.action === 'pay'))}}" class="footer-btn pay-btn" bindtap="doInitiatorPay" disabled="{{paying}}">
|
||||
<image class="btn-icon" src="/assets/icons/wallet.svg" mode="aspectFit"/>
|
||||
<text>{{paying ? '支付中...' : (isCreateMode ? '去支付' : '立即支付')}}</text>
|
||||
</button>
|
||||
<!-- 发起人 action=share:发送给好友 -->
|
||||
<button wx:elif="{{isInitiator && detail.action === 'share'}}" class="footer-btn share-btn" open-type="share">
|
||||
<image class="btn-icon" src="/assets/icons/share.svg" mode="aspectFit"/>
|
||||
<text>发送给好友</text>
|
||||
</button>
|
||||
<!-- 好友 action=redeem:领取并阅读 -->
|
||||
<button wx:elif="{{!isInitiator && detail.action === 'redeem'}}" class="footer-btn redeem-btn" bindtap="doRedeem" disabled="{{redeeming}}">
|
||||
<image class="btn-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"/>
|
||||
<text>{{redeeming ? '领取中...' : '领取并阅读'}}</text>
|
||||
</button>
|
||||
<!-- 好友 action=alreadyRedeemed:已领取,去阅读 -->
|
||||
<button wx:elif="{{!isInitiator && detail.action === 'alreadyRedeemed'}}" class="footer-btn redeem-btn" bindtap="goToArticle">
|
||||
<image class="btn-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"/>
|
||||
<text>已领取,去阅读</text>
|
||||
</button>
|
||||
<!-- 好友 action=wait:待发起人支付 -->
|
||||
<view wx:else class="footer-btn footer-btn-disabled">
|
||||
<text>待发起人支付</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录弹窗(好友领取时未登录) -->
|
||||
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
|
||||
<view class="modal-content login-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="login-title">登录 Soul创业派对</text>
|
||||
<text class="login-desc">登录后可免费领取并阅读</text>
|
||||
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{!agreeProtocol}}">
|
||||
<text class="btn-wechat-icon">微</text>
|
||||
<text>微信快捷登录</text>
|
||||
</button>
|
||||
<view class="login-agree-row" catchtap="toggleAgree">
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
|
||||
<text class="agree-text">我已阅读并同意</text>
|
||||
<text class="agree-link" bindtap="openUserProtocol">《用户协议》</text>
|
||||
<text class="agree-text">和</text>
|
||||
<text class="agree-link" bindtap="openPrivacy">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 背景光效 -->
|
||||
<view class="bg-effects">
|
||||
<view class="bg-glow bg-glow-1"></view>
|
||||
<view class="bg-glow bg-glow-2"></view>
|
||||
<view class="bg-dots"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* Soul创业派对 - 代付详情页 */
|
||||
/* Soul创业派对 - 代付详情页(参考 yulan 深色主题、青绿主色) */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #0a0a0a 0%, #000 40%, #000 100%);
|
||||
background: #050505;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
@@ -10,10 +11,10 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(20rpx);
|
||||
-webkit-backdrop-filter: blur(20rpx);
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(5, 5, 5, 0.6);
|
||||
backdrop-filter: blur(40rpx);
|
||||
-webkit-backdrop-filter: blur(40rpx);
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
@@ -32,7 +33,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.nav-back:active {
|
||||
@@ -45,65 +45,176 @@
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20rpx;
|
||||
padding: 24rpx 24rpx 200rpx;
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
/* 骨架屏 */
|
||||
.skeleton-wrap {
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-hero {
|
||||
background: rgba(24, 24, 27, 0.8);
|
||||
border-radius: 32rpx;
|
||||
padding: 40rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero-badge {
|
||||
width: 120rpx;
|
||||
height: 40rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero-title {
|
||||
width: 80%;
|
||||
height: 48rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero-desc {
|
||||
width: 60%;
|
||||
height: 32rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-hero-amount {
|
||||
width: 200rpx;
|
||||
height: 64rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
background: rgba(24, 24, 27, 0.6);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 4rpx solid rgba(0, 206, 209, 0.2);
|
||||
border-top-color: #00CED1;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
.skeleton-info .skeleton-line {
|
||||
height: 32rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
.skeleton-info .skeleton-line { width: 70%; }
|
||||
.skeleton-info .skeleton-line.short { width: 45%; }
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
/* 产品 Hero 卡片 */
|
||||
.hero-card {
|
||||
position: relative;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
/* 营销:章节标题+内容预览,与订单卡片统一风格 */
|
||||
.article-preview {
|
||||
background: linear-gradient(145deg, #1a1a1c 0%, #141416 100%);
|
||||
border-radius: 24rpx;
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
inset: -4rpx;
|
||||
background: linear-gradient(180deg, rgba(20, 184, 166, 0.2) 0%, transparent 100%);
|
||||
border-radius: 40rpx;
|
||||
filter: blur(24rpx);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
background: rgba(24, 24, 27, 0.8);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 40rpx;
|
||||
padding: 48rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-decor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border: 1rpx solid rgba(0, 206, 209, 0.1);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
.hero-decor-img {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
font-size: 20rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2em;
|
||||
color: #14b8a6;
|
||||
background: rgba(20, 184, 166, 0.1);
|
||||
border: 1rpx solid rgba(20, 184, 166, 0.2);
|
||||
padding: 6rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12rpx;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.5rpx;
|
||||
margin: 0 0 16rpx;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.65;
|
||||
.hero-desc {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
@@ -111,154 +222,440 @@
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 订单卡片:与文章预览统一圆角、边距 */
|
||||
.card {
|
||||
background: linear-gradient(145deg, #1a1a1c 0%, #141416 100%);
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
border: 1rpx solid rgba(0, 206, 209, 0.1);
|
||||
.hero-footer {
|
||||
margin-top: 40rpx;
|
||||
padding-top: 32rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 24rpx;
|
||||
.hero-amount-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
display: inline-block;
|
||||
font-size: 22rpx;
|
||||
color: rgba(0, 206, 209, 0.9);
|
||||
background: rgba(0, 206, 209, 0.08);
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
letter-spacing: 0.5rpx;
|
||||
.hero-amount-label {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.initiator {
|
||||
display: block;
|
||||
.hero-amount-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.gift-quantity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin: 12rpx 0;
|
||||
}
|
||||
|
||||
.create-tip {
|
||||
margin-top: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.gift-quantity-input {
|
||||
width: 120rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 20rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.3rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
height: 1rpx;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
|
||||
margin: 0 24rpx;
|
||||
.hero-currency {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20rpx 24rpx 24rpx;
|
||||
.hero-amount {
|
||||
font-size: 60rpx;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: #fff;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.row {
|
||||
.hero-arrow-wrap {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 32rpx;
|
||||
background: rgba(20, 184, 166, 0.1);
|
||||
border: 1rpx solid rgba(20, 184, 166, 0.2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-arrow {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
filter: invert(72%) sepia(45%) saturate(800%) hue-rotate(130deg);
|
||||
}
|
||||
|
||||
/* 发起人信息卡片 */
|
||||
.requester-card {
|
||||
background: rgba(24, 24, 27, 0.3);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 48rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
.product-row .value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.amount-row .amount {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #00CED1;
|
||||
letter-spacing: 1rpx;
|
||||
text-shadow: 0 0 24rpx rgba(0, 206, 209, 0.3);
|
||||
}
|
||||
|
||||
/* 提示文案 */
|
||||
.tips {
|
||||
.requester-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10rpx;
|
||||
padding: 0 4rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.tips-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
opacity: 0.8;
|
||||
.requester-header:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 主按钮 */
|
||||
.pay-btn {
|
||||
width: 100%;
|
||||
.requester-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
background: linear-gradient(135deg, #00CED1 0%, #18a8a8 50%, #20B2AA 100%);
|
||||
color: #fff;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 50rpx;
|
||||
border: none;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 206, 209, 0.35);
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #3f3f46 0%, #18181b 100%);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pay-btn:active {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.99);
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pay-btn[disabled] {
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
.icon-avatar {
|
||||
padding: 24rpx;
|
||||
filter: brightness(0) invert(0.6);
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
.requester-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requester-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.requester-label {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.requester-msg-wrap {
|
||||
position: relative;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.requester-msg-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4rpx;
|
||||
background: rgba(20, 184, 166, 0.3);
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.requester-msg {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 安全徽章 */
|
||||
.security-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.btn-icon-img {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
filter: brightness(0) invert(1);
|
||||
.security-icon {
|
||||
font-size: 32rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.security-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 120rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* 底部浮动操作栏 */
|
||||
.footer-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.footer-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(24, 24, 27, 0.9);
|
||||
backdrop-filter: blur(40rpx);
|
||||
-webkit-backdrop-filter: blur(40rpx);
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50rpx;
|
||||
box-shadow: 0 -20rpx 100rpx rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
padding: 24rpx 24rpx 24rpx 48rpx;
|
||||
}
|
||||
|
||||
.footer-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.footer-amount {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: #fff;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.footer-currency {
|
||||
font-size: 28rpx;
|
||||
color: #14b8a6;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 32rpx 48rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
box-shadow: 0 16rpx 40rpx rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.footer-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
background: #14b8a6;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pay-btn {
|
||||
background: #14b8a6;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pay-btn[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.redeem-btn {
|
||||
background: #14b8a6;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.redeem-btn[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.footer-tip-single {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.footer-btn-disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.share-btn .btn-icon,
|
||||
.pay-btn .btn-icon,
|
||||
.redeem-btn .btn-icon {
|
||||
filter: brightness(0);
|
||||
}
|
||||
|
||||
/* 背景光效 */
|
||||
.bg-effects {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.bg-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(150rpx);
|
||||
}
|
||||
|
||||
.bg-glow-1 {
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
width: 80%;
|
||||
height: 60%;
|
||||
background: rgba(20, 184, 166, 0.05);
|
||||
animation: pulse-slow 8s infinite;
|
||||
}
|
||||
|
||||
.bg-glow-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
width: 60%;
|
||||
height: 50%;
|
||||
background: rgba(20, 184, 166, 0.05);
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.bg-dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(255,255,255,0.02) 1rpx, transparent 1rpx);
|
||||
background-size: 64rpx 64rpx;
|
||||
}
|
||||
|
||||
/* 登录弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
}
|
||||
.modal-content.login-modal {
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
background: #1c1c1e;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
font-size: 32rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.login-icon { font-size: 96rpx; display: block; margin-bottom: 24rpx; }
|
||||
.login-title { font-size: 36rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 16rpx; }
|
||||
.login-desc { font-size: 26rpx; color: rgba(255, 255, 255, 0.6); display: block; margin-bottom: 48rpx; }
|
||||
.btn-wechat {
|
||||
width: 100%;
|
||||
padding: 28rpx;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 24rpx;
|
||||
border: none;
|
||||
}
|
||||
.btn-wechat-disabled { opacity: 0.5; }
|
||||
.btn-wechat-icon { font-weight: 700; margin-right: 8rpx; }
|
||||
.login-agree-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 32rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.agree-checkbox {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8rpx;
|
||||
margin-right: 12rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.agree-checked { background: #14b8a6; border-color: #14b8a6; }
|
||||
.agree-link { color: #14b8a6; }
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* Soul创业派对 - 我的代付
|
||||
* Tab: 我发起的 / 我帮付的
|
||||
* Soul创业派对 - 我发起的代付(改造后:仅我发起的,含领取记录)
|
||||
*/
|
||||
const app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
tab: 'requests',
|
||||
requests: [],
|
||||
payments: [],
|
||||
loading: false
|
||||
},
|
||||
|
||||
@@ -19,17 +16,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (this.data.requests.length > 0 || this.data.payments.length > 0) {
|
||||
if (this.data.requests.length > 0) {
|
||||
this.loadData()
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab || 'requests'
|
||||
this.setData({ tab })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
const userId = app.globalData.userInfo?.id || ''
|
||||
if (!userId) {
|
||||
@@ -38,13 +29,8 @@ Page({
|
||||
}
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
if (this.data.tab === 'requests') {
|
||||
const res = await app.request(`/api/miniprogram/gift-pay/my-requests?userId=${encodeURIComponent(userId)}`)
|
||||
this.setData({ requests: (res && res.list) || [], loading: false })
|
||||
} else {
|
||||
const res = await app.request(`/api/miniprogram/gift-pay/my-payments?userId=${encodeURIComponent(userId)}`)
|
||||
this.setData({ payments: (res && res.list) || [], loading: false })
|
||||
}
|
||||
const res = await app.request(`/api/miniprogram/gift-pay/my-requests?userId=${encodeURIComponent(userId)}`)
|
||||
this.setData({ requests: (res && res.list) || [], loading: false })
|
||||
} catch (e) {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
@@ -53,18 +39,21 @@ Page({
|
||||
goToDetail(e) {
|
||||
const requestSn = e.currentTarget.dataset.sn
|
||||
if (requestSn) {
|
||||
wx.navigateTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}` })
|
||||
wx.navigateTo({ url: `/pages/gift-pay/redemption-detail?requestSn=${encodeURIComponent(requestSn)}` })
|
||||
}
|
||||
},
|
||||
|
||||
shareRequest(e) {
|
||||
e.stopPropagation()
|
||||
wx.showToast({ title: '请点击右上角「...」分享给好友', icon: 'none', duration: 2500 })
|
||||
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
|
||||
const requestSn = e?.currentTarget?.dataset?.sn
|
||||
if (requestSn) {
|
||||
wx.navigateTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}` })
|
||||
}
|
||||
},
|
||||
|
||||
async cancelRequest(e) {
|
||||
e.stopPropagation()
|
||||
const requestSn = e.currentTarget.dataset.sn
|
||||
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
|
||||
const requestSn = e?.currentTarget?.dataset?.sn
|
||||
if (!requestSn) return
|
||||
const ok = await new Promise(r => {
|
||||
wx.showModal({ title: '取消代付', content: '确定取消该代付请求?', success: res => r(res.confirm) })
|
||||
@@ -78,7 +67,8 @@ Page({
|
||||
})
|
||||
if (res && res.success) {
|
||||
wx.showToast({ title: '已取消', icon: 'success' })
|
||||
this.loadData()
|
||||
const requests = (this.data.requests || []).filter(r => r.requestSn !== requestSn)
|
||||
this.setData({ requests })
|
||||
} else {
|
||||
wx.showToast({ title: res?.error || '取消失败', icon: 'none' })
|
||||
}
|
||||
|
||||
@@ -1,64 +1,52 @@
|
||||
<!-- Soul创业派对 - 我的代付 -->
|
||||
<!-- Soul创业派对 - 我的代付(改造后:仅我发起的,含领取记录) -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-arrow">←</text>
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-arrow"></icon>
|
||||
</view>
|
||||
<view class="nav-info">
|
||||
<text class="nav-title">我的代付</text>
|
||||
<text class="nav-title">我发起的代付</text>
|
||||
</view>
|
||||
<view class="nav-right-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
|
||||
<view class="tab {{tab === 'requests' ? 'active' : ''}}" data-tab="requests" bindtap="switchTab">我发起的</view>
|
||||
<view class="tab {{tab === 'payments' ? 'active' : ''}}" data-tab="payments" bindtap="switchTab">我帮付的</view>
|
||||
</view>
|
||||
|
||||
<view class="content">
|
||||
<view class="content" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
|
||||
<block wx:if="{{loading}}">
|
||||
<view class="loading-box">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{tab === 'requests'}}">
|
||||
<block wx:if="{{requests.length === 0}}">
|
||||
<view class="empty">暂无发起的代付</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="card" wx:for="{{requests}}" wx:key="requestSn" bindtap="goToDetail" data-sn="{{item.requestSn}}">
|
||||
<view class="card-row">
|
||||
<text class="desc">{{item.description}}</text>
|
||||
<text class="amount">¥{{item.amount}}</text>
|
||||
</view>
|
||||
<view class="card-row">
|
||||
<text class="status {{item.status}}">{{item.status === 'pending' ? '待支付' : item.status === 'paid' ? '已支付' : item.status === 'cancelled' ? '已取消' : '已过期'}}</text>
|
||||
<view class="actions" wx:if="{{item.status === 'pending'}}">
|
||||
<text class="action-text" bindtap="shareRequest" data-sn="{{item.requestSn}}">分享</text>
|
||||
<text class="action-text cancel" bindtap="cancelRequest" data-sn="{{item.requestSn}}">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{requests.length === 0}}">
|
||||
<view class="empty">暂无发起的代付</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<block wx:if="{{payments.length === 0}}">
|
||||
<view class="empty">暂无帮付记录</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="card" wx:for="{{payments}}" wx:key="requestSn" bindtap="goToDetail" data-sn="{{item.requestSn}}">
|
||||
<view class="card-row">
|
||||
<text class="desc">{{item.description}}</text>
|
||||
<text class="amount">¥{{item.amount}}</text>
|
||||
<view class="card" wx:for="{{requests}}" wx:key="requestSn" bindtap="goToDetail" data-sn="{{item.requestSn}}">
|
||||
<view class="card-row">
|
||||
<text class="desc">{{item.description}}</text>
|
||||
<text class="amount">¥{{item.amount}}</text>
|
||||
</view>
|
||||
<view class="card-row card-meta">
|
||||
<text class="quantity" wx:if="{{item.quantity > 1}}">{{item.quantity}}份</text>
|
||||
<text class="redeemed" wx:if="{{item.status === 'paid'}}">已领 {{item.redeemedCount || 0}}/{{item.quantity || 1}}</text>
|
||||
<text class="status {{item.status}}">{{item.status === 'pending' || item.status === 'pending_pay' ? '待支付' : item.status === 'paid' ? '已支付' : item.status === 'cancelled' ? '已取消' : '已过期'}}</text>
|
||||
<view class="actions" wx:if="{{item.status === 'pending' || item.status === 'pending_pay'}}">
|
||||
<text class="action-text cancel" catchtap="cancelRequest" data-sn="{{item.requestSn}}">取消</text>
|
||||
</view>
|
||||
<view class="card-row">
|
||||
<text class="status {{item.status}}">{{item.status === 'paid' ? '已支付' : item.status}}</text>
|
||||
<view class="actions" wx:elif="{{item.status === 'paid'}}">
|
||||
<text class="action-text" catchtap="shareRequest" data-sn="{{item.requestSn}}">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view class="redeem-list" wx:if="{{item.redeemList && item.redeemList.length > 0}}">
|
||||
<text class="redeem-title">领取记录:</text>
|
||||
<view class="redeem-item" wx:for="{{item.redeemList}}" wx:for-item="redeem" wx:key="userId">
|
||||
<text class="redeem-nickname">{{redeem.nickname || '用户'}}</text>
|
||||
<text class="redeem-time">{{redeem.redeemAt}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -43,28 +43,6 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 24rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 12rpx;
|
||||
background: #1c1c1e;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #00CED1;
|
||||
background: rgba(0, 206, 209, 0.15);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 32rpx 32rpx;
|
||||
}
|
||||
@@ -120,6 +98,46 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.quantity, .redeemed {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.redeem-list {
|
||||
margin-top: 16rpx;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.redeem-title {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.redeem-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4rpx 0;
|
||||
}
|
||||
|
||||
.redeem-nickname {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.redeem-time {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
|
||||
80
miniprogram/pages/gift-pay/redemption-detail.js
Normal file
80
miniprogram/pages/gift-pay/redemption-detail.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Soul创业派对 - 代付领取详情(发起人查看:文章信息、领取人明细、剩余份数)
|
||||
*/
|
||||
const app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
requestSn: '',
|
||||
detail: null,
|
||||
loading: true,
|
||||
remaining: 0
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
const requestSn = (options.requestSn || '').trim()
|
||||
if (!requestSn) {
|
||||
wx.showToast({ title: '链接无效', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
this.setData({ requestSn })
|
||||
this.loadDetail()
|
||||
},
|
||||
|
||||
async loadDetail() {
|
||||
const { requestSn } = this.data
|
||||
const userId = app.globalData.userInfo?.id || ''
|
||||
if (!userId) {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
const res = await app.request(
|
||||
`/api/miniprogram/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}&userId=${encodeURIComponent(userId)}`
|
||||
)
|
||||
if (res && res.success) {
|
||||
const q = res.quantity || 0
|
||||
const redeemed = res.redeemedCount || 0
|
||||
const remaining = Math.max(0, q - redeemed)
|
||||
this.setData({
|
||||
detail: res,
|
||||
remaining,
|
||||
loading: false
|
||||
})
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: res?.error || '加载失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
goToDetail() {
|
||||
const { requestSn } = this.data
|
||||
if (requestSn) {
|
||||
wx.navigateTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}` })
|
||||
}
|
||||
},
|
||||
|
||||
goToArticle() {
|
||||
const { detail } = this.data
|
||||
if (!detail) return
|
||||
const mid = detail.productMid || 0
|
||||
const id = detail.productId || ''
|
||||
if (detail.productType === 'section' && (mid || id)) {
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
}
|
||||
},
|
||||
|
||||
goBack() {
|
||||
app.goBackOrToHome()
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/gift-pay/redemption-detail.json
Normal file
3
miniprogram/pages/gift-pay/redemption-detail.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
71
miniprogram/pages/gift-pay/redemption-detail.wxml
Normal file
71
miniprogram/pages/gift-pay/redemption-detail.wxml
Normal file
@@ -0,0 +1,71 @@
|
||||
<!-- Soul创业派对 - 代付领取详情(文章信息、领取人明细、剩余份数) -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-arrow"></icon>
|
||||
</view>
|
||||
<view class="nav-info">
|
||||
<text class="nav-title">领取详情</text>
|
||||
</view>
|
||||
<view class="nav-right-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
|
||||
<block wx:if="{{loading}}">
|
||||
<view class="loading-box">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{detail}}">
|
||||
<!-- 文章信息 -->
|
||||
<section class="article-card">
|
||||
<text class="article-title">{{detail.sectionTitle || detail.description || '代付商品'}}</text>
|
||||
<text class="article-preview" wx:if="{{detail.contentPreview}}">{{detail.contentPreview}}</text>
|
||||
<view class="article-meta">
|
||||
<text class="meta-label">总份数</text>
|
||||
<text class="meta-value">{{detail.quantity || 0}} 份</text>
|
||||
</view>
|
||||
<view class="article-meta">
|
||||
<text class="meta-label">剩余份数</text>
|
||||
<text class="meta-value highlight">{{remaining}} 份</text>
|
||||
</view>
|
||||
<view class="article-actions">
|
||||
<view class="btn-link" bindtap="goToArticle" wx:if="{{detail.productType === 'section' && (detail.productMid || detail.productId)}}">
|
||||
<text>去阅读</text>
|
||||
</view>
|
||||
<view class="btn-link" bindtap="goToDetail">
|
||||
<text>{{detail.status === 'paid' ? '去分享' : detail.status === 'pending_pay' ? '去支付' : '查看详情'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section>
|
||||
|
||||
<!-- 领取人明细 -->
|
||||
<section class="redeem-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">领取记录</text>
|
||||
<text class="section-count" wx:if="{{detail.redeemList && detail.redeemList.length > 0}}">共 {{detail.redeemList.length}} 人</text>
|
||||
</view>
|
||||
<view class="redeem-list" wx:if="{{detail.redeemList && detail.redeemList.length > 0}}">
|
||||
<view class="redeem-item" wx:for="{{detail.redeemList}}" wx:key="userId">
|
||||
<view class="redeem-user">
|
||||
<image class="redeem-avatar" src="{{item.avatar || '/assets/icons/user.svg'}}" mode="aspectFill"/>
|
||||
<text class="redeem-nickname">{{item.nickname || '用户'}}</text>
|
||||
</view>
|
||||
<text class="redeem-time">{{item.redeemAt}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="redeem-empty" wx:else>
|
||||
<text>暂无领取记录</text>
|
||||
</view>
|
||||
</section>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="empty">
|
||||
<text>代付请求不存在或已处理</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
234
miniprogram/pages/gift-pay/redemption-detail.wxss
Normal file
234
miniprogram/pages/gift-pay/redemption-detail.wxss
Normal file
@@ -0,0 +1,234 @@
|
||||
/* Soul创业派对 - 代付领取详情 */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(5, 5, 5, 0.6);
|
||||
backdrop-filter: blur(40rpx);
|
||||
-webkit-backdrop-filter: blur(40rpx);
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-back:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
font-size: 36rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24rpx 24rpx 80rpx;
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border: 4rpx solid rgba(20, 184, 166, 0.2);
|
||||
border-top-color: #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 文章信息卡片 */
|
||||
.article-card {
|
||||
background: rgba(24, 24, 27, 0.8);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.article-preview {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 0;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.meta-value.highlight {
|
||||
color: #14b8a6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
padding: 16rpx 32rpx;
|
||||
background: rgba(20, 184, 166, 0.15);
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.btn-link:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 领取记录 */
|
||||
.redeem-section {
|
||||
background: rgba(24, 24, 27, 0.8);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.redeem-list {
|
||||
max-height: 400rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.redeem-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.redeem-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.redeem-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.redeem-avatar {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.redeem-nickname {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.redeem-time {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.redeem-empty {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
@@ -4,9 +4,10 @@
|
||||
* 技术支持: 存客宝
|
||||
*/
|
||||
|
||||
console.log('[Index] ===== 首页文件开始加载 =====')
|
||||
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -20,13 +21,15 @@ Page({
|
||||
readCount: 0,
|
||||
|
||||
// 书籍数据
|
||||
totalSections: 0,
|
||||
totalSections: 62,
|
||||
bookData: [],
|
||||
|
||||
// 精选推荐(按热度排行,默认显示3篇,可展开更多)
|
||||
featuredSections: [],
|
||||
featuredSectionsAll: [],
|
||||
featuredExpanded: false,
|
||||
// 推荐章节
|
||||
featuredSections: [
|
||||
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
|
||||
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
|
||||
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
|
||||
],
|
||||
|
||||
// 最新章节(动态计算)
|
||||
latestSection: null,
|
||||
@@ -45,10 +48,9 @@ Page({
|
||||
superMembers: [],
|
||||
superMembersLoading: true,
|
||||
|
||||
// 最新新增章节
|
||||
// 最新新增章节(完整列表 + 展示列表,用于展开/折叠)
|
||||
latestChapters: [],
|
||||
latestChaptersExpanded: false,
|
||||
latestChaptersAll: [],
|
||||
displayLatestChapters: [],
|
||||
|
||||
// 篇章数(从 bookData 计算)
|
||||
partCount: 0,
|
||||
@@ -58,10 +60,24 @@ Page({
|
||||
|
||||
// 链接卡若 - 留资弹窗
|
||||
showLeadModal: false,
|
||||
leadPhone: ''
|
||||
leadPhone: '',
|
||||
|
||||
// 展开状态(首页精选/最新)
|
||||
featuredExpanded: false,
|
||||
latestExpanded: false,
|
||||
featuredSectionsFull: [], // 展开时用 book/hot 加载的完整列表
|
||||
featuredExpandedLoading: false,
|
||||
|
||||
// 功能配置(搜索开关)
|
||||
searchEnabled: true,
|
||||
|
||||
// 审核模式:隐藏支付相关入口
|
||||
auditMode: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
console.log('[Index] ===== onLoad 触发 =====')
|
||||
|
||||
// 获取系统信息
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight,
|
||||
@@ -70,19 +86,27 @@ Page({
|
||||
|
||||
// 处理分享参数(推荐码绑定)
|
||||
if (options && options.ref) {
|
||||
console.log('[Index] 检测到推荐码:', options.ref)
|
||||
app.handleReferralCode({ query: options })
|
||||
}
|
||||
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
this.loadFeatureConfig()
|
||||
this.initData()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
console.log('[Index] onShow 触发')
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
const tabBar = this.getTabBar()
|
||||
console.log('[Index] TabBar 组件:', tabBar ? '已找到' : '未找到')
|
||||
|
||||
// 主动触发配置加载
|
||||
if (tabBar && tabBar.loadFeatureConfig) {
|
||||
console.log('[Index] 主动调用 TabBar.loadFeatureConfig()')
|
||||
tabBar.loadFeatureConfig()
|
||||
}
|
||||
|
||||
@@ -92,153 +116,144 @@ Page({
|
||||
} else if (tabBar) {
|
||||
tabBar.setData({ selected: 0 })
|
||||
}
|
||||
} else {
|
||||
console.log('[Index] TabBar 组件未找到或 getTabBar 方法不存在')
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
this.updateUserStatus()
|
||||
|
||||
// 规则引擎:首页展示时检查(填头像、分享引导等)
|
||||
checkAndExecute('page_show', this)
|
||||
},
|
||||
|
||||
// 初始化数据:首次进页面并行异步加载,加快首屏展示
|
||||
initData() {
|
||||
Promise.all([
|
||||
this.loadBookData(),
|
||||
this.loadFeaturedFromServer(),
|
||||
this.loadSuperMembers(),
|
||||
this.loadLatestChapters()
|
||||
]).finally(() => {
|
||||
this.setData({ loading: false })
|
||||
})
|
||||
this.setData({ loading: false })
|
||||
this.loadBookData()
|
||||
this.loadFeaturedAndLatest()
|
||||
this.loadSuperMembers()
|
||||
},
|
||||
|
||||
async loadSuperMembers() {
|
||||
this.setData({ superMembersLoading: true })
|
||||
try {
|
||||
// 优先加载 VIP 会员(购买 1980 fullbook/vip 订单的用户)
|
||||
// 并行请求 VIP 会员和普通用户,合并后取前 4 个(VIP 优先)
|
||||
const [vipRes, usersRes] = await Promise.all([
|
||||
app.request({ url: '/api/miniprogram/vip/members', silent: true }).catch(() => null),
|
||||
app.request({ url: '/api/miniprogram/users?limit=20', silent: true }).catch(() => null)
|
||||
])
|
||||
let members = []
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/vip/members', silent: true })
|
||||
if (res && res.success && res.data) {
|
||||
// 不再过滤无头像用户,无头像时用首字母展示
|
||||
members = (Array.isArray(res.data) ? res.data : []).slice(0, 4).map(u => ({
|
||||
id: u.id,
|
||||
name: u.nickname || u.vipName || u.vip_name || '会员', // 超级个体:用户资料优先,随「我的」修改实时生效
|
||||
avatar: u.avatar || '',
|
||||
isVip: true
|
||||
}))
|
||||
}
|
||||
} catch (e) {}
|
||||
// 不足 4 个则用有头像的普通用户补充
|
||||
if (members.length < 4) {
|
||||
try {
|
||||
const dbRes = await app.request({ url: '/api/miniprogram/users?limit=20', silent: true })
|
||||
if (dbRes && dbRes.success && dbRes.data) {
|
||||
const existIds = new Set(members.map(m => m.id))
|
||||
const extra = (Array.isArray(dbRes.data) ? dbRes.data : [])
|
||||
.filter(u => u.avatar && u.nickname && !existIds.has(u.id))
|
||||
.slice(0, 4 - members.length)
|
||||
.map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 }))
|
||||
members = members.concat(extra)
|
||||
}
|
||||
} catch (e) {}
|
||||
if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) {
|
||||
members = vipRes.data.slice(0, 4).map(u => ({
|
||||
id: u.id,
|
||||
name: u.nickname || u.vipName || u.vip_name || '会员',
|
||||
avatar: u.avatar || '',
|
||||
isVip: true
|
||||
}))
|
||||
if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '人')
|
||||
}
|
||||
if (members.length < 4 && usersRes && usersRes.success && Array.isArray(usersRes.data)) {
|
||||
const existIds = new Set(members.map(m => m.id))
|
||||
const extra = usersRes.data
|
||||
.filter(u => u.avatar && u.nickname && !existIds.has(u.id))
|
||||
.slice(0, 4 - members.length)
|
||||
.map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 }))
|
||||
members = members.concat(extra)
|
||||
}
|
||||
this.setData({ superMembers: members, superMembersLoading: false })
|
||||
} catch (e) {
|
||||
console.log('[Index] 加载超级个体失败:', e)
|
||||
this.setData({ superMembersLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 从服务端获取精选推荐(按热度排行)和最新更新
|
||||
async loadFeaturedFromServer() {
|
||||
// 精选推荐 + 最新更新 + 最新列表:一次请求 recommended + latest-chapters,避免重复
|
||||
async loadFeaturedAndLatest() {
|
||||
try {
|
||||
// 1. 精选推荐:从 book/hot 获取热度排行数据
|
||||
try {
|
||||
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
|
||||
if (hotRes && hotRes.success && Array.isArray(hotRes.data) && hotRes.data.length > 0) {
|
||||
const tagClassMap = { '热门': 'tag-hot', '推荐': 'tag-rec', '精选': 'tag-rec' }
|
||||
const all = hotRes.data.map((s, i) => ({
|
||||
id: s.id || s.section_id,
|
||||
mid: s.mid ?? s.MID ?? 0,
|
||||
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
|
||||
part: (s.partTitle || s.part_title || '').replace(/[_||]/g, ' ').trim(),
|
||||
tag: s.tag || '',
|
||||
tagClass: tagClassMap[s.tag] || 'tag-rec',
|
||||
hotScore: s.hotScore || s.hot_score || 0,
|
||||
hotRank: s.hotRank || (i + 1),
|
||||
price: s.price ?? 1,
|
||||
}))
|
||||
this.setData({
|
||||
featuredSectionsAll: all,
|
||||
featuredSections: all.slice(0, 3),
|
||||
featuredExpanded: false,
|
||||
})
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 2. 最新更新:用 book/latest-chapters 取第1条(排除「序言」「尾声」「附录」)
|
||||
try {
|
||||
const latestRes = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
|
||||
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
|
||||
const latestList = rawList.filter(l => {
|
||||
const pt = (l.part_title || l.partTitle || '').toLowerCase()
|
||||
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
|
||||
})
|
||||
if (latestList.length > 0) {
|
||||
const l = latestList[0]
|
||||
this.setData({
|
||||
latestSection: {
|
||||
id: l.id,
|
||||
mid: l.mid ?? l.MID ?? 0,
|
||||
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
|
||||
part: l.part_title || l.partTitle || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const chapters = (res && res.data) || (res && res.chapters) || []
|
||||
const valid = chapters.filter(c => {
|
||||
const pt = (c.part_title || c.partTitle || '').toLowerCase()
|
||||
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
|
||||
})
|
||||
if (valid.length > 0) {
|
||||
valid.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
|
||||
const latest = valid[0]
|
||||
this.setData({
|
||||
latestSection: {
|
||||
id: latest.id,
|
||||
mid: latest.mid ?? latest.MID ?? 0,
|
||||
title: latest.section_title || latest.sectionTitle || latest.title || latest.chapterTitle || '',
|
||||
part: latest.part_title || latest.partTitle || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
const excludeFixed = (c) => {
|
||||
const pt = (c.part_title || c.partTitle || '').toLowerCase()
|
||||
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
|
||||
}
|
||||
} catch (e) {}
|
||||
const toSection = (s, i, tagMap = ['热门', '推荐', '精选']) => ({
|
||||
id: s.id || s.section_id,
|
||||
mid: s.mid ?? s.MID ?? 0,
|
||||
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
|
||||
part: (s.part_title || s.partTitle || '').replace(/[_||]/g, ' ').trim(),
|
||||
tag: s.tag || tagMap[i] || '精选',
|
||||
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
|
||||
})
|
||||
|
||||
const [recRes, latestRes] = await Promise.all([
|
||||
app.request({ url: '/api/miniprogram/book/recommended', silent: true }).catch(() => null),
|
||||
app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true }).catch(() => null)
|
||||
])
|
||||
|
||||
// 1. 精选推荐(recommended → hot 兜底)
|
||||
let featured = []
|
||||
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
|
||||
featured = recRes.data.map((s, i) => toSection(s, i))
|
||||
}
|
||||
if (featured.length === 0) {
|
||||
try {
|
||||
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=10', silent: true })
|
||||
const hotList = (hotRes && hotRes.data) ? hotRes.data : []
|
||||
if (hotList.length > 0) featured = hotList.slice(0, 3).map((s, i) => toSection(s, i))
|
||||
} catch (e) { console.log('[Index] book/hot 兜底失败:', e) }
|
||||
}
|
||||
if (featured.length > 0) this.setData({ featuredSections: featured })
|
||||
|
||||
// 2. 最新更新 + 最新列表(共用 latest-chapters 数据)
|
||||
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
|
||||
const latestList = rawList.filter(excludeFixed)
|
||||
if (latestList.length > 0) {
|
||||
const l = latestList[0]
|
||||
this.setData({
|
||||
latestSection: {
|
||||
id: l.id,
|
||||
mid: l.mid ?? l.MID ?? 0,
|
||||
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
|
||||
part: l.part_title || l.partTitle || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
const latestChapters = latestList.slice(0, 20).map(c => {
|
||||
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
||||
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
|
||||
return {
|
||||
id: c.id,
|
||||
mid: c.mid ?? c.MID ?? 0,
|
||||
title,
|
||||
desc: '',
|
||||
price: c.price ?? 1,
|
||||
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
})
|
||||
const display = this.data.latestExpanded ? latestChapters : latestChapters.slice(0, 5)
|
||||
this.setData({ latestChapters, displayLatestChapters: display })
|
||||
} catch (e) {
|
||||
console.log('[Index] 从服务端加载推荐失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async loadBookData() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
if (res && (res.data || res.chapters)) {
|
||||
const chapters = res.data || res.chapters || []
|
||||
const partIds = new Set(chapters.map(c => c.partId || c.part_id || '').filter(Boolean))
|
||||
const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
|
||||
if (res?.success) {
|
||||
const total = res.totalSections ?? 0
|
||||
const parts = res.parts || []
|
||||
app.globalData.totalSections = total || 62
|
||||
this.setData({
|
||||
bookData: chapters,
|
||||
totalSections: res.total || chapters.length || app.globalData.totalSections || 0,
|
||||
partCount: partIds.size || 5
|
||||
totalSections: app.globalData.totalSections,
|
||||
partCount: parts.length || 5
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载书籍数据失败:', e)
|
||||
this.setData({ totalSections: app.globalData.totalSections || 62, partCount: 5 })
|
||||
}
|
||||
},
|
||||
|
||||
// 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的)
|
||||
updateUserStatus() {
|
||||
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
|
||||
const readCount = Math.min(app.getReadCount(), this.data.totalSections || app.globalData.totalSections || 0)
|
||||
const readCount = Math.min(app.getReadCount(), this.data.totalSections || 62)
|
||||
this.setData({
|
||||
isLoggedIn,
|
||||
hasFullBook,
|
||||
@@ -248,21 +263,46 @@ Page({
|
||||
|
||||
// 跳转到目录
|
||||
goToChapters() {
|
||||
trackClick('home', 'nav_click', '目录')
|
||||
trackClick('home', 'nav_click', '阅读进度')
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
|
||||
if (hasCachedFeatures) {
|
||||
this.setData({
|
||||
searchEnabled: app.globalData.features.searchEnabled,
|
||||
auditMode: app.globalData.auditMode || false
|
||||
})
|
||||
return
|
||||
}
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || {}
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
const auditMode = !!mp.auditMode
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
app.globalData.auditMode = auditMode
|
||||
this.setData({ searchEnabled, auditMode })
|
||||
} catch (e) {
|
||||
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
|
||||
}
|
||||
},
|
||||
|
||||
// 跳转到搜索页
|
||||
goToSearch() {
|
||||
if (!this.data.searchEnabled) return
|
||||
trackClick('home', 'nav_click', '搜索')
|
||||
wx.navigateTo({ url: '/pages/search/search' })
|
||||
},
|
||||
|
||||
// 跳转到阅读页(优先传 mid,与分享逻辑一致)
|
||||
// 跳转到阅读页(传 mid,与分享一致;无 mid 时传 id)
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('home', 'card_click', e.currentTarget.dataset.id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
trackClick('home', 'card_click', id || '章节')
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
@@ -273,14 +313,10 @@ Page({
|
||||
},
|
||||
|
||||
goToVip() {
|
||||
trackClick('home', 'btn_click', 'VIP')
|
||||
trackClick('home', 'btn_click', '加入创业派对')
|
||||
wx.navigateTo({ url: '/pages/vip/vip' })
|
||||
},
|
||||
|
||||
goToAbout() {
|
||||
wx.navigateTo({ url: '/pages/about/about' })
|
||||
},
|
||||
|
||||
async onLinkKaruo() {
|
||||
trackClick('home', 'btn_click', '链接卡若')
|
||||
const app = getApp()
|
||||
@@ -297,31 +333,23 @@ Page({
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo.id
|
||||
// 2 分钟内只能点一次(与后端限频一致)
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
let phone = (app.globalData.userInfo.phone || '').trim()
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
|
||||
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
|
||||
if (!phone && !wechatId) {
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
phone = (profileRes.data.phone || '').trim()
|
||||
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
|
||||
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if ((!phone && !wechatId) || !avatar) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (phone || wechatId) {
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
@@ -337,12 +365,8 @@ Page({
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: '卡若会主动添加你微信,请注意你的微信消息',
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
@@ -405,6 +429,11 @@ Page({
|
||||
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const app = getApp()
|
||||
const userId = app.globalData.userInfo?.id
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
@@ -421,6 +450,7 @@ Page({
|
||||
wx.hideLoading()
|
||||
this.setData({ showLeadModal: false, leadPhone: '' })
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
// 同步手机号到用户资料
|
||||
try {
|
||||
if (userId) {
|
||||
@@ -449,7 +479,6 @@ Page({
|
||||
},
|
||||
|
||||
async submitLead() {
|
||||
trackClick('home', 'btn_click', '提交留资')
|
||||
const phone = (this.data.leadPhone || '').trim().replace(/\s/g, '')
|
||||
if (!phone) {
|
||||
wx.showToast({ title: '请输入手机号', icon: 'none' })
|
||||
@@ -462,80 +491,55 @@ Page({
|
||||
wx.switchTab({ url: '/pages/match/match' })
|
||||
},
|
||||
|
||||
async loadLatestChapters() {
|
||||
try {
|
||||
let chapters = app.globalData.bookData || []
|
||||
if (!Array.isArray(chapters) || chapters.length === 0) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
chapters = (res && res.data) || (res && res.chapters) || []
|
||||
}
|
||||
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
|
||||
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
|
||||
let candidates = chapters.filter(c => (c.isNew || c.is_new) === true && exclude(c))
|
||||
if (candidates.length === 0) {
|
||||
candidates = chapters.filter(exclude)
|
||||
}
|
||||
const sessionNum = (c) => {
|
||||
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
|
||||
const m = title.match(/第\s*(\d+)\s*场/) || title.match(/第(\d+)场/)
|
||||
if (m) return parseInt(m[1], 10)
|
||||
const id = c.id != null ? String(c.id) : ''
|
||||
if (/^\d+$/.test(id)) return parseInt(id, 10)
|
||||
return 0
|
||||
}
|
||||
const mapChapter = (c) => {
|
||||
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
||||
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
|
||||
const rawContent = (c.content || '').replace(/<[^>]+>/g, '').trim()
|
||||
let desc = ''
|
||||
if (rawContent && rawContent.length > 0) {
|
||||
const clean = rawContent.replace(/^#[\d.]+\s*/, '').trim()
|
||||
desc = clean.length > 36 ? clean.slice(0, 36) + '...' : clean
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
mid: c.mid ?? c.MID ?? 0,
|
||||
title,
|
||||
desc,
|
||||
price: c.price ?? 1,
|
||||
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
}
|
||||
const sorted = candidates.sort((a, b) => {
|
||||
const na = sessionNum(a)
|
||||
const nb = sessionNum(b)
|
||||
if (na !== nb) return nb - na
|
||||
return new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0)
|
||||
})
|
||||
const latestAll = sorted.slice(0, 10).map(mapChapter)
|
||||
this.setData({
|
||||
latestChaptersAll: latestAll,
|
||||
latestChapters: latestAll.slice(0, 5),
|
||||
latestChaptersExpanded: false,
|
||||
})
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
toggleLatestExpand() {
|
||||
const all = this.data.latestChaptersAll || []
|
||||
if (this.data.latestChaptersExpanded) {
|
||||
this.setData({ latestChapters: all.slice(0, 5), latestChaptersExpanded: false })
|
||||
} else {
|
||||
this.setData({ latestChapters: all, latestChaptersExpanded: true })
|
||||
}
|
||||
},
|
||||
|
||||
toggleFeaturedExpand() {
|
||||
const all = this.data.featuredSectionsAll || []
|
||||
// 精选推荐:展开/折叠
|
||||
async toggleFeaturedExpanded() {
|
||||
if (this.data.featuredExpandedLoading) return
|
||||
trackClick('home', 'tab_click', this.data.featuredExpanded ? '精选收起' : '精选展开')
|
||||
if (this.data.featuredExpanded) {
|
||||
this.setData({ featuredSections: all.slice(0, 3), featuredExpanded: false })
|
||||
} else {
|
||||
this.setData({ featuredSections: all, featuredExpanded: true })
|
||||
const collapsed = this.data.featuredSectionsFull.length > 0 ? this.data.featuredSectionsFull.slice(0, 3) : this.data.featuredSections
|
||||
this.setData({ featuredExpanded: false, featuredSections: collapsed })
|
||||
return
|
||||
}
|
||||
if (this.data.featuredSectionsFull.length > 0) {
|
||||
this.setData({ featuredExpanded: true, featuredSections: this.data.featuredSectionsFull })
|
||||
return
|
||||
}
|
||||
this.setData({ featuredExpandedLoading: true })
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
|
||||
const list = (res && res.data) ? res.data : []
|
||||
const tagMap = ['热门', '推荐', '精选']
|
||||
const full = list.map((s, i) => ({
|
||||
id: s.id || s.section_id,
|
||||
mid: s.mid ?? s.MID ?? 0,
|
||||
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
|
||||
part: (s.partTitle || s.part_title || '').replace(/[_||]/g, ' ').trim(),
|
||||
tag: tagMap[i % 3] || '精选',
|
||||
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i % 3] || 'tag-rec'
|
||||
}))
|
||||
this.setData({
|
||||
featuredSectionsFull: full,
|
||||
featuredSections: full,
|
||||
featuredExpanded: true,
|
||||
featuredExpandedLoading: false
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[Index] 加载精选更多失败:', e)
|
||||
this.setData({ featuredExpandedLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 最新新增:展开/折叠(默认 5 条,点击展开剩余)
|
||||
toggleLatestExpanded() {
|
||||
trackClick('home', 'tab_click', this.data.latestExpanded ? '最新收起' : '最新展开')
|
||||
const expanded = !this.data.latestExpanded
|
||||
const display = expanded ? this.data.latestChapters : this.data.latestChapters.slice(0, 5)
|
||||
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
|
||||
},
|
||||
|
||||
goToMemberDetail(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('home', 'card_click', '超级个体_' + (id || ''))
|
||||
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${id}` })
|
||||
},
|
||||
|
||||
@@ -548,9 +552,8 @@ Page({
|
||||
async onPullDownRefresh() {
|
||||
await Promise.all([
|
||||
this.loadBookData(),
|
||||
this.loadFeaturedFromServer(),
|
||||
this.loadSuperMembers(),
|
||||
this.loadLatestChapters()
|
||||
this.loadFeaturedAndLatest(),
|
||||
this.loadSuperMembers()
|
||||
])
|
||||
this.updateUserStatus()
|
||||
wx.stopPullDownRefresh()
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar" bindtap="goToSearch">
|
||||
<!-- 搜索栏(根据配置显示) -->
|
||||
<view class="search-bar" wx:if="{{searchEnabled}}" bindtap="goToSearch">
|
||||
<view class="search-icon-wrap">
|
||||
<text class="search-icon-text">🔍</text>
|
||||
<icon name="search" size="40" color="#8e8e93" customClass="search-icon-text"></icon>
|
||||
</view>
|
||||
<text class="search-placeholder">搜索章节标题或内容...</text>
|
||||
</view>
|
||||
@@ -38,22 +38,22 @@
|
||||
<!-- Banner卡片 - 最新章节(异步加载) -->
|
||||
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
|
||||
<view class="banner-glow"></view>
|
||||
<view class="banner-tag">推荐</view>
|
||||
<view class="banner-tag">最新更新</view>
|
||||
<view class="banner-title">{{latestSection.title}}</view>
|
||||
<view class="banner-action">
|
||||
<text class="banner-action-text">点击阅读</text>
|
||||
<view class="banner-arrow">→</view>
|
||||
<text class="banner-action-text">开始阅读</text>
|
||||
<icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
|
||||
<view class="banner-glow"></view>
|
||||
<view class="banner-tag">推荐</view>
|
||||
<view class="banner-tag">最新更新</view>
|
||||
<view class="banner-title">加载中...</view>
|
||||
<view class="banner-action"><text class="banner-action-text">点击阅读</text><view class="banner-arrow">→</view></view>
|
||||
<view class="banner-action"><text class="banner-action-text">开始阅读</text><icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon></view>
|
||||
</view>
|
||||
|
||||
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
|
||||
<view class="section">
|
||||
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏) -->
|
||||
<view class="section" wx:if="{{!auditMode}}">
|
||||
<view class="section-header">
|
||||
<text class="section-title">超级个体</text>
|
||||
</view>
|
||||
@@ -87,14 +87,18 @@
|
||||
<!-- 已加载无数据 -->
|
||||
<view wx:else class="super-empty">
|
||||
<text class="super-empty-text">成为会员,展示你的项目</text>
|
||||
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 →</view>
|
||||
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 <icon name="chevron-right" size="28" color="#00CED1" customClass="inline-arrow"></icon></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 精选推荐(按热度排行,默认3篇,可展开更多) -->
|
||||
<view class="section" wx:if="{{featuredSections.length > 0}}">
|
||||
<!-- 精选推荐(带 tag,支持展开更多) -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">精选推荐</text>
|
||||
<view class="section-more" wx:if="{{featuredSections.length > 0}}" bindtap="toggleFeaturedExpanded">
|
||||
<text class="more-text">{{featuredExpandedLoading ? '加载中...' : (featuredExpanded ? '收起' : '展开更多')}}</text>
|
||||
<icon name="{{featuredExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="featured-list">
|
||||
<view
|
||||
@@ -107,47 +111,50 @@
|
||||
>
|
||||
<view class="featured-content">
|
||||
<view class="featured-meta">
|
||||
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
|
||||
<text class="featured-id brand-color">{{item.id}}</text>
|
||||
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag || '精选'}}</text>
|
||||
</view>
|
||||
<text class="featured-title">{{item.title}}</text>
|
||||
</view>
|
||||
<view class="featured-arrow">›</view>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.6)" customClass="featured-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="expand-btn" bindtap="toggleFeaturedExpand" wx:if="{{(featuredSectionsAll.length || 0) > 3}}">
|
||||
<text class="expand-text">{{featuredExpanded ? '收起' : '展开更多'}}</text>
|
||||
<text class="expand-icon">{{featuredExpanded ? '∧' : '∨'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新新增(时间线样式)+ 展开/收起 -->
|
||||
<!-- 最新新增(时间线样式,支持展开更多) -->
|
||||
<view class="section" wx:if="{{latestChapters.length > 0}}">
|
||||
<view class="section-header latest-header">
|
||||
<text class="section-title">最新新增</text>
|
||||
<view class="daily-badge-wrap">
|
||||
<text class="daily-badge">+{{latestChaptersAll.length || latestChapters.length}}</text>
|
||||
<view class="section-header-right">
|
||||
<view class="daily-badge-wrap">
|
||||
<text class="daily-badge">+{{latestChapters.length}}</text>
|
||||
</view>
|
||||
<view class="section-more" wx:if="{{latestChapters.length > 5}}" bindtap="toggleLatestExpanded">
|
||||
<text class="more-text">{{latestExpanded ? '收起' : '展开更多'}}</text>
|
||||
<icon name="{{latestExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="timeline-wrap">
|
||||
<view class="timeline-line"></view>
|
||||
<view class="timeline-list">
|
||||
<view class="timeline-item {{index === 0 ? 'timeline-item-first' : ''}}" wx:for="{{latestChapters}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
|
||||
<view class="timeline-item {{index === 0 ? 'timeline-item-first' : ''}}" wx:for="{{displayLatestChapters}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
|
||||
<view class="timeline-dot"></view>
|
||||
<view class="timeline-content">
|
||||
<view class="timeline-row">
|
||||
<text class="timeline-title">{{item.title}}</text>
|
||||
<view class="timeline-left">
|
||||
<text class="latest-new-tag">NEW</text>
|
||||
<text class="timeline-title">{{item.title}}</text>
|
||||
</view>
|
||||
<view class="timeline-right">
|
||||
<text class="timeline-price">¥{{item.price}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 展开/收起按钮 -->
|
||||
<view class="expand-btn" bindtap="toggleLatestExpand" wx:if="{{(latestChaptersAll.length || 0) > 5}}">
|
||||
<text class="expand-text">{{latestChaptersExpanded ? '收起' : '展开更多'}}</text>
|
||||
<text class="expand-icon">{{latestChaptersExpanded ? '∧' : '∨'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content" style="height: {{navBarHeight - statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-arrow">←</text>
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-arrow"></icon>
|
||||
</view>
|
||||
<view class="nav-title">
|
||||
<text class="nav-title-text">{{title}}</text>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
const app = getApp()
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
// 默认匹配类型配置
|
||||
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
|
||||
@@ -14,10 +14,10 @@ const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
// 导师顾问:跳转到存客宝添加微信
|
||||
// 团队招募:跳转到存客宝添加微信
|
||||
let MATCH_TYPES = [
|
||||
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false },
|
||||
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
|
||||
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: '❤️', matchFromDB: true, showJoinAfterMatch: true },
|
||||
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: '🎮', matchFromDB: true, showJoinAfterMatch: true }
|
||||
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'star', matchFromDB: true, showJoinAfterMatch: false },
|
||||
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: 'users', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
|
||||
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: 'heart', matchFromDB: true, showJoinAfterMatch: true },
|
||||
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: 'gamepad', matchFromDB: true, showJoinAfterMatch: true }
|
||||
]
|
||||
|
||||
let FREE_MATCH_LIMIT = 3 // 每日免费匹配次数
|
||||
@@ -105,7 +105,9 @@ Page({
|
||||
// 加载匹配配置
|
||||
async loadMatchConfig() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET' })
|
||||
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 更新全局配置,导师顾问类型强制显示「导师顾问」
|
||||
@@ -196,8 +198,8 @@ Page({
|
||||
|
||||
// 选择匹配类型
|
||||
selectType(e) {
|
||||
trackClick('match', 'tab_click', e.currentTarget.dataset.type || '类型选择')
|
||||
const typeId = e.currentTarget.dataset.type
|
||||
trackClick('match', 'tab_click', typeId || '类型')
|
||||
const type = MATCH_TYPES.find(t => t.id === typeId)
|
||||
this.setData({
|
||||
selectedType: typeId,
|
||||
@@ -207,7 +209,7 @@ Page({
|
||||
|
||||
// 点击匹配按钮
|
||||
async handleMatchClick() {
|
||||
trackClick('match', 'btn_click', '开始匹配')
|
||||
trackClick('match', 'btn_click', '匹配_' + (this.data.selectedType || ''))
|
||||
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
|
||||
|
||||
// 导师顾问:先播匹配动画,动画完成后再跳转(不在此处直接跳)
|
||||
@@ -309,7 +311,7 @@ Page({
|
||||
confirmText: '去购买',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
wx.switchTab({ url: '/pages/catalog/catalog' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -365,7 +367,7 @@ Page({
|
||||
}, 500)
|
||||
|
||||
// 1.5-3秒后:导师顾问→跳转;其他类型→弹窗
|
||||
const delay = Math.random() * 7000 + 3000
|
||||
const delay = Math.random() * 1500 + 1500
|
||||
setTimeout(() => {
|
||||
clearInterval(timer)
|
||||
this.setData({ isMatching: false })
|
||||
@@ -414,15 +416,14 @@ Page({
|
||||
// 从数据库获取真实用户匹配
|
||||
let matchedUser = null
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/match/users',
|
||||
silent: true,
|
||||
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
matchType: this.data.selectedType,
|
||||
userId: app.globalData.userInfo?.id || ''
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
matchedUser = res.data
|
||||
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
|
||||
@@ -432,8 +433,8 @@ Page({
|
||||
}
|
||||
|
||||
// 延迟显示结果(模拟匹配过程)
|
||||
const delay = Math.random() * 7000 + 3000
|
||||
const timeoutId = setTimeout(() => {
|
||||
const delay = Math.random() * 2000 + 2000
|
||||
setTimeout(() => {
|
||||
clearInterval(timer)
|
||||
|
||||
// 如果没有匹配到用户,提示用户
|
||||
@@ -463,9 +464,39 @@ Page({
|
||||
|
||||
// 上报匹配行为到存客宝
|
||||
this.reportMatch(matchedUser)
|
||||
|
||||
}, delay)
|
||||
},
|
||||
|
||||
// 生成模拟匹配数据
|
||||
generateMockMatch() {
|
||||
const nicknames = ['创业先锋', '资源整合者', '私域专家', '导师顾问', '连续创业者']
|
||||
const concepts = [
|
||||
'专注私域流量运营5年,帮助100+品牌实现从0到1的增长。',
|
||||
'连续创业者,擅长商业模式设计和资源整合。',
|
||||
'在Soul分享真实创业故事,希望找到志同道合的合作伙伴。'
|
||||
]
|
||||
const wechats = ['soul_partner_1', 'soul_business_2024', 'soul_startup_fan']
|
||||
|
||||
const index = Math.floor(Math.random() * nicknames.length)
|
||||
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
|
||||
|
||||
return {
|
||||
id: `user_${Date.now()}`,
|
||||
nickname: nicknames[index],
|
||||
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
|
||||
tags: ['创业者', '私域运营', currentType?.label || '创业合伙'],
|
||||
matchScore: Math.floor(Math.random() * 20) + 80,
|
||||
concept: concepts[index % concepts.length],
|
||||
wechat: wechats[index % wechats.length],
|
||||
commonInterests: [
|
||||
{ icon: 'book-open', text: '都在读《创业派对》' },
|
||||
{ icon: 'briefcase', text: '对私域运营感兴趣' },
|
||||
{ icon: 'target', text: '相似的创业方向' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 上报匹配行为
|
||||
async reportMatch(matchedUser) {
|
||||
try {
|
||||
@@ -484,15 +515,6 @@ Page({
|
||||
}
|
||||
}
|
||||
})
|
||||
// 记录匹配行为到 user_tracks
|
||||
const uid = app.globalData.userInfo?.id
|
||||
if (uid) {
|
||||
app.request('/api/miniprogram/track', {
|
||||
method: 'POST',
|
||||
data: { userId: uid, action: 'match', target: matchedUser?.id || '', extraData: { matchType: this.data.selectedType } },
|
||||
silent: true
|
||||
}).catch(() => {})
|
||||
}
|
||||
// 匹配后规则:引导填写 MBTI/行业信息
|
||||
checkAndExecute('after_match', this)
|
||||
} catch (e) {
|
||||
@@ -512,7 +534,6 @@ Page({
|
||||
|
||||
// 添加微信好友
|
||||
handleAddWechat() {
|
||||
trackClick('match', 'btn_click', '加好友')
|
||||
if (!this.data.currentMatch) return
|
||||
|
||||
wx.setClipboardData({
|
||||
@@ -563,7 +584,6 @@ Page({
|
||||
|
||||
// 提交加入
|
||||
async handleJoinSubmit() {
|
||||
trackClick('match', 'btn_click', '加入提交')
|
||||
const { contactType, phoneNumber, wechatId, joinType, isJoining, canHelp, needHelp } = this.data
|
||||
|
||||
if (isJoining) return
|
||||
@@ -619,16 +639,18 @@ Page({
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
} else {
|
||||
this.setData({
|
||||
joinSuccess: false,
|
||||
joinError: res.error || '提交失败,请稍后重试'
|
||||
})
|
||||
// 即使API返回失败,也模拟成功(因为已保存本地)
|
||||
this.setData({ joinSuccess: true })
|
||||
setTimeout(() => {
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
this.setData({
|
||||
joinSuccess: false,
|
||||
joinError: e.message || '网络异常,请稍后重试'
|
||||
})
|
||||
// 网络错误时也模拟成功
|
||||
this.setData({ joinSuccess: true })
|
||||
setTimeout(() => {
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
} finally {
|
||||
this.setData({ isJoining: false })
|
||||
}
|
||||
@@ -652,7 +674,6 @@ Page({
|
||||
|
||||
// 购买匹配次数
|
||||
async buyMatchCount() {
|
||||
trackClick('match', 'btn_click', '购买次数')
|
||||
this.setData({ showUnlockModal: false })
|
||||
|
||||
try {
|
||||
@@ -706,7 +727,19 @@ Page({
|
||||
if (e.errMsg && e.errMsg.includes('cancel')) {
|
||||
wx.showToast({ title: '已取消', icon: 'none' })
|
||||
} else {
|
||||
wx.showToast({ title: e.message || '支付失败,请稍后重试', icon: 'none' })
|
||||
// 测试模式
|
||||
wx.showModal({
|
||||
title: '支付服务暂不可用',
|
||||
content: '是否使用测试模式购买?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
|
||||
wx.setStorageSync('extra_match_count', extraMatches)
|
||||
wx.showToast({ title: '测试购买成功', icon: 'success' })
|
||||
this.initUserStatus()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -717,6 +750,11 @@ Page({
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
// 打开资料修改页(找伙伴右上角图标)
|
||||
openSettings() {
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
},
|
||||
|
||||
// 阻止事件冒泡
|
||||
preventBubble() {},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- 匹配提示条 - 简化显示 -->
|
||||
<view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}">
|
||||
<text class="tip-icon">⚡</text>
|
||||
<icon name="zap" size="36" color="#FFD700" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">今日免费次数已用完</text>
|
||||
<view class="tip-btn" bindtap="showUnlockModal">购买次数</view>
|
||||
</view>
|
||||
@@ -36,12 +36,12 @@
|
||||
<view class="sphere-gradient"></view>
|
||||
<view class="sphere-content">
|
||||
<block wx:if="{{needPayToMatch}}">
|
||||
<text class="sphere-icon">⚡</text>
|
||||
<icon name="zap" size="56" color="#FFD700" customClass="sphere-icon"></icon>
|
||||
<text class="sphere-title gold-text">购买次数</text>
|
||||
<text class="sphere-desc">¥1 = 1次匹配</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<text class="sphere-icon">👥</text>
|
||||
<icon name="users" size="64" color="#00CED1" customClass="sphere-icon"></icon>
|
||||
<text class="sphere-title">开始匹配</text>
|
||||
<text class="sphere-desc">匹配{{currentTypeLabel}}</text>
|
||||
</block>
|
||||
@@ -68,7 +68,7 @@
|
||||
bindtap="selectType"
|
||||
data-type="{{item.id}}"
|
||||
>
|
||||
<text class="type-icon">{{item.icon}}</text>
|
||||
<icon name="{{item.icon}}" size="48" color="{{selectedType === item.id ? '#00CED1' : '#8e8e93'}}" customClass="type-icon"></icon>
|
||||
<text class="type-label {{selectedType === item.id ? 'text-brand' : ''}}">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -86,14 +86,14 @@
|
||||
<!-- 内层球体 -->
|
||||
<view class="matching-core">
|
||||
<view class="matching-core-inner">
|
||||
<text class="matching-icon-v2">🔍</text>
|
||||
<icon name="search" size="48" color="#00CED1" customClass="matching-icon-v2"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 粒子效果 -->
|
||||
<view class="particle particle-1">✨</view>
|
||||
<view class="particle particle-2">💫</view>
|
||||
<view class="particle particle-3">⭐</view>
|
||||
<view class="particle particle-4">🌟</view>
|
||||
<view class="particle particle-1"><icon name="sparkles" size="24" color="#FFD700"></icon></view>
|
||||
<view class="particle particle-2"><icon name="sparkles" size="20" color="#00CED1"></icon></view>
|
||||
<view class="particle particle-3"><icon name="star" size="22" color="#FFD700"></icon></view>
|
||||
<view class="particle particle-4"><icon name="star" size="18" color="#00CED1"></icon></view>
|
||||
<!-- 扩散波纹 -->
|
||||
<view class="ripple-v2 ripple-v2-1"></view>
|
||||
<view class="ripple-v2 ripple-v2-2"></view>
|
||||
@@ -102,9 +102,9 @@
|
||||
<text class="matching-title-v2">正在匹配{{currentTypeLabel}}...</text>
|
||||
<text class="matching-subtitle-v2">正在从 {{matchAttempts * 127 + 89}} 位创业者中为你寻找</text>
|
||||
<view class="matching-tips">
|
||||
<text class="tip-item" wx:if="{{matchAttempts >= 1}}">✓ 分析兴趣标签</text>
|
||||
<text class="tip-item" wx:if="{{matchAttempts >= 2}}">✓ 匹配创业方向</text>
|
||||
<text class="tip-item" wx:if="{{matchAttempts >= 3}}">✓ 筛选优质伙伴</text>
|
||||
<view class="tip-item" wx:if="{{matchAttempts >= 1}}"><icon name="check" size="24" color="#34C759"></icon><text>分析兴趣标签</text></view>
|
||||
<view class="tip-item" wx:if="{{matchAttempts >= 2}}"><icon name="check" size="24" color="#34C759"></icon><text>匹配创业方向</text></view>
|
||||
<view class="tip-item" wx:if="{{matchAttempts >= 3}}"><icon name="check" size="24" color="#34C759"></icon><text>筛选优质伙伴</text></view>
|
||||
</view>
|
||||
<view class="cancel-btn-v2" bindtap="cancelMatch">取消</view>
|
||||
</view>
|
||||
@@ -115,7 +115,7 @@
|
||||
<view class="matched-state">
|
||||
<!-- 成功动画 -->
|
||||
<view class="success-icon-wrapper">
|
||||
<text class="success-icon">✨</text>
|
||||
<icon name="sparkles" size="64" color="#FFD700" customClass="success-icon"></icon>
|
||||
</view>
|
||||
|
||||
<!-- 用户卡片 -->
|
||||
@@ -139,7 +139,7 @@
|
||||
<text class="section-title">共同兴趣</text>
|
||||
<view class="interest-list">
|
||||
<view class="interest-item" wx:for="{{currentMatch.commonInterests}}" wx:key="text">
|
||||
<text class="interest-icon">{{item.icon}}</text>
|
||||
<icon name="{{item.icon}}" size="28" color="#00CED1" customClass="interest-icon"></icon>
|
||||
<text class="interest-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -167,7 +167,7 @@
|
||||
<!-- 成功状态 -->
|
||||
<block wx:if="{{joinSuccess}}">
|
||||
<view class="join-success-new">
|
||||
<view class="success-icon-big">✅</view>
|
||||
<view class="success-icon-big"><icon name="check" size="80" color="#34C759"></icon></view>
|
||||
<text class="success-title-new">提交成功</text>
|
||||
<text class="success-desc-new">工作人员将在24小时内与您联系</text>
|
||||
</view>
|
||||
@@ -178,12 +178,12 @@
|
||||
<!-- 头部 -->
|
||||
<view class="join-header">
|
||||
<view class="join-icon-wrap">
|
||||
<text class="join-icon">{{joinType === 'investor' ? '👥' : joinType === 'mentor' ? '❤️' : '🎮'}}</text>
|
||||
<icon name="{{joinType === 'investor' ? 'users' : joinType === 'mentor' ? 'heart' : 'gamepad'}}" size="64" color="#00CED1" customClass="join-icon"></icon>
|
||||
</view>
|
||||
<text class="join-title">{{joinTypeLabel}}</text>
|
||||
<text class="join-subtitle" wx:if="{{needBindFirst}}">请先绑定联系方式</text>
|
||||
<text class="join-subtitle" wx:else>填写联系方式,专人对接</text>
|
||||
<view class="close-btn-new" bindtap="closeJoinModal">✕</view>
|
||||
<view class="close-btn-new" bindtap="closeJoinModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式切换 -->
|
||||
@@ -193,7 +193,7 @@
|
||||
bindtap="switchContactType"
|
||||
data-type="phone"
|
||||
>
|
||||
<text class="switch-icon">📱</text>
|
||||
<icon name="smartphone" size="36" color="#00CED1" customClass="switch-icon"></icon>
|
||||
<text>手机号</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -201,7 +201,7 @@
|
||||
bindtap="switchContactType"
|
||||
data-type="wechat"
|
||||
>
|
||||
<text class="switch-icon">💬</text>
|
||||
<icon name="message-circle" size="36" color="#00CED1" customClass="switch-icon"></icon>
|
||||
<text>微信号</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -278,14 +278,14 @@
|
||||
<view class="form-input-wrap">
|
||||
<text class="form-label">手机号</text>
|
||||
<view class="form-input-inner">
|
||||
<text class="form-icon">📱</text>
|
||||
<icon name="smartphone" size="36" color="#8e8e93" customClass="form-icon"></icon>
|
||||
<input class="form-input" type="tel" placeholder="请输入您的手机号" value="{{contactPhone}}" bindinput="onContactPhoneInput"/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-input-wrap">
|
||||
<text class="form-label">微信号</text>
|
||||
<view class="form-input-inner">
|
||||
<text class="form-icon">💬</text>
|
||||
<icon name="message-circle" size="36" color="#8e8e93" customClass="form-icon"></icon>
|
||||
<input class="form-input" type="text" placeholder="请输入您的微信号" value="{{contactWechat}}" bindinput="onContactWechatInput"/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -299,7 +299,7 @@
|
||||
<!-- 解锁弹窗 -->
|
||||
<view class="modal-overlay" wx:if="{{showUnlockModal}}" bindtap="closeUnlockModal">
|
||||
<view class="modal-content unlock-modal" catchtap="preventBubble">
|
||||
<view class="unlock-icon">⚡</view>
|
||||
<view class="unlock-icon"><icon name="zap" size="64" color="#FFD700"></icon></view>
|
||||
<text class="unlock-title">购买匹配次数</text>
|
||||
<text class="unlock-desc">今日3次免费匹配已用完,可付费购买额外次数</text>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="nav-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="#5EEAD4" customClass="nav-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">个人资料</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
@@ -25,7 +25,7 @@
|
||||
<text class="profile-name">{{member.name}}</text>
|
||||
<view class="profile-tags" wx:if="{{member.mbti || member.region}}">
|
||||
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
|
||||
<text class="tag tag-region" wx:if="{{member.region}}"><text class="pin-icon">📍</text>{{member.region}}</text>
|
||||
<view class="tag tag-region" wx:if="{{member.region}}"><icon name="map-pin" size="24" color="currentColor" customClass="pin-icon"></icon><text>{{member.region}}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- 基本信息(未填写行已隐藏) -->
|
||||
<view class="card" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}">
|
||||
<view class="card-head">
|
||||
<text class="card-icon">👤</text>
|
||||
<icon name="user" size="48" color="#00CED1" customClass="card-icon"></icon>
|
||||
<text class="card-label">基本信息</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
@@ -61,7 +61,7 @@
|
||||
<view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockField" data-field="contact">
|
||||
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact">📋</view>
|
||||
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
|
||||
@@ -71,7 +71,7 @@
|
||||
<view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockField" data-field="wechat">
|
||||
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat">📋</view>
|
||||
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -80,22 +80,22 @@
|
||||
<!-- 个人故事(未填写行已隐藏) -->
|
||||
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
|
||||
<view class="card-head">
|
||||
<text class="card-icon bulb">💡</text>
|
||||
<icon name="lightbulb" size="48" color="#FFD700" customClass="card-icon bulb"></icon>
|
||||
<text class="card-label">个人故事</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="story" wx:if="{{member.bestMonth}}">
|
||||
<view class="story-head"><text class="story-icon">🏆</text><text class="story-q">最赚钱的一个月做的是什么</text></view>
|
||||
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月做的是什么</text></view>
|
||||
<text class="story-a">{{member.bestMonth}}</text>
|
||||
</view>
|
||||
<view class="divider" wx:if="{{member.bestMonth}}"></view>
|
||||
<view class="story" wx:if="{{member.achievement}}">
|
||||
<view class="story-head"><text class="story-icon">⭐</text><text class="story-q">最有成就感的一件事</text></view>
|
||||
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最有成就感的一件事</text></view>
|
||||
<text class="story-a">{{member.achievement}}</text>
|
||||
</view>
|
||||
<view class="divider" wx:if="{{member.achievement}}"></view>
|
||||
<view class="story" wx:if="{{member.turningPoint}}">
|
||||
<view class="story-head"><text class="story-icon turn">🔄</text><text class="story-q">人生的转折点</text></view>
|
||||
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-icon turn"></icon><text class="story-q">人生的转折点</text></view>
|
||||
<text class="story-a">{{member.turningPoint}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -104,7 +104,7 @@
|
||||
<!-- 互助需求(未填写行已隐藏) -->
|
||||
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
|
||||
<view class="card-head">
|
||||
<text class="card-icon">🤝</text>
|
||||
<icon name="handshake" size="48" color="#00CED1" customClass="card-icon"></icon>
|
||||
<text class="card-label">互助需求</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
@@ -122,7 +122,7 @@
|
||||
<!-- 项目介绍 -->
|
||||
<view class="card" wx:if="{{member.project}}">
|
||||
<view class="card-head">
|
||||
<text class="card-icon rocket">🚀</text>
|
||||
<icon name="rocket" size="48" color="#00CED1" customClass="card-icon rocket"></icon>
|
||||
<text class="card-label">项目介绍</text>
|
||||
</view>
|
||||
<text class="proj-txt">{{member.project}}</text>
|
||||
@@ -132,7 +132,7 @@
|
||||
<view class="bottom-wrap">
|
||||
<view class="btn-super" bindtap="goToVip">
|
||||
<text>成为超级个体</text>
|
||||
<text class="btn-arrow">→</text>
|
||||
<icon name="chevron-right" size="36" color="#00CED1" customClass="btn-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view style="height:160rpx;"></view>
|
||||
@@ -144,7 +144,7 @@
|
||||
<text class="state-txt">加载中...</text>
|
||||
</view>
|
||||
<view class="state-wrap" wx:if="{{!loading && !member}}">
|
||||
<text class="state-emoji">👤</text>
|
||||
<icon name="user" size="80" color="#3a3a3c" customClass="state-emoji"></icon>
|
||||
<text class="state-txt">暂无该超级个体信息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 导师详情 -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">导师详情</text>
|
||||
<view class="nav-placeholder-r"></view>
|
||||
</view>
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
<view class="bottom-btn-area">
|
||||
<view class="contact-btn" bindtap="onContactTap">
|
||||
<text class="contact-icon">💬</text>
|
||||
<icon name="message-circle" size="40" color="#00CED1" customClass="contact-icon"></icon>
|
||||
<text>联系导师</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -81,7 +81,7 @@
|
||||
<view class="modal-content" catchtap="">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">选择咨询项目</text>
|
||||
<text class="modal-close" bindtap="closeConsultModal">✕</text>
|
||||
<view class="modal-close" bindtap="closeConsultModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
<view class="consult-options">
|
||||
<view
|
||||
@@ -107,7 +107,8 @@
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<view class="confirm-btn" bindtap="onConfirmConsult" disabled="{{creating}}">
|
||||
{{creating ? '处理中...' : '确认选择 →'}}
|
||||
<text>{{creating ? '处理中...' : '确认选择'}}</text>
|
||||
<icon wx:if="{{!creating}}" name="chevron-right" size="28" color="#fff" customClass="confirm-arrow"></icon>
|
||||
</view>
|
||||
<text class="footer-hint">点击确认即代表同意 <text class="footer-link">服务协议</text></text>
|
||||
</view>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 选择导师 -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">选择导师</text>
|
||||
<view class="nav-placeholder-r"></view>
|
||||
</view>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<view class="search-bar">
|
||||
<view class="search-input-wrap">
|
||||
<text class="search-icon">🔍</text>
|
||||
<icon name="search" size="36" color="#8e8e93" customClass="search-icon"></icon>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索导师、技能或行业..."
|
||||
@@ -37,7 +37,10 @@
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">推荐导师</text>
|
||||
<text class="section-more" bindtap="loadMentors">查看全部 ›</text>
|
||||
<view class="section-more" bindtap="loadMentors">
|
||||
<text>查看全部</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.6)" customClass="section-more-icon"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="loading" wx:if="{{loading}}">加载中...</view>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
const app = getApp()
|
||||
const { formatStatNum } = require('../../utils/util.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -19,7 +19,7 @@ Page({
|
||||
userInfo: null,
|
||||
|
||||
// 统计数据
|
||||
totalSections: 0,
|
||||
totalSections: 62,
|
||||
readCount: 0,
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
@@ -30,12 +30,18 @@ Page({
|
||||
// 阅读统计
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0,
|
||||
readCountText: '0',
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0',
|
||||
|
||||
// 最近阅读
|
||||
recentChapters: [],
|
||||
|
||||
// 功能配置
|
||||
matchEnabled: false,
|
||||
referralEnabled: true,
|
||||
auditMode: false,
|
||||
searchEnabled: true,
|
||||
|
||||
// VIP状态
|
||||
isVip: false,
|
||||
@@ -72,26 +78,29 @@ Page({
|
||||
contactSaving: false,
|
||||
pendingWithdraw: false,
|
||||
|
||||
// 我的余额(wallet 页入口展示)
|
||||
walletBalance: 0,
|
||||
// 设置入口:开发版、体验版显示
|
||||
showSettingsEntry: false,
|
||||
|
||||
// 我的代付链接
|
||||
giftList: [],
|
||||
// 我的余额
|
||||
walletBalanceText: '--',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
|
||||
const envVersion = accountInfo?.miniProgram?.envVersion || ''
|
||||
const showSettingsEntry = envVersion === 'develop' || envVersion === 'trial'
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight,
|
||||
navBarHeight: app.globalData.navBarHeight
|
||||
navBarHeight: app.globalData.navBarHeight,
|
||||
showSettingsEntry
|
||||
})
|
||||
this.loadFeatureConfig()
|
||||
this.initUserStatus()
|
||||
// 规则引擎:登录后检查(填头像等)
|
||||
checkAndExecute('after_login', this)
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
// 设置TabBar选中状态(根据 matchEnabled 动态设置)
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
const tabBar = this.getTabBar()
|
||||
@@ -107,12 +116,19 @@ Page({
|
||||
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const res = await app.request('/api/miniprogram/config')
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || (res && res.data && res.data.features) || {}
|
||||
this.setData({ matchEnabled: features.matchEnabled === true })
|
||||
const matchEnabled = features.matchEnabled === true
|
||||
const referralEnabled = features.referralEnabled !== false
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
const auditMode = !!mp.auditMode
|
||||
app.globalData.auditMode = auditMode
|
||||
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
|
||||
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
|
||||
} catch (error) {
|
||||
console.log('加载功能配置失败:', error)
|
||||
this.setData({ matchEnabled: false })
|
||||
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -138,27 +154,33 @@ Page({
|
||||
earningsLoading: true,
|
||||
recentChapters: [],
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0
|
||||
matchHistory: 0,
|
||||
readCountText: '0',
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0'
|
||||
})
|
||||
this.loadDashboardStats()
|
||||
this.loadMyEarnings()
|
||||
this.loadPendingConfirm()
|
||||
this.loadVipStatus()
|
||||
this.loadWalletBalance()
|
||||
this.loadGiftList()
|
||||
} else {
|
||||
const guestReadCount = app.getReadCount()
|
||||
this.setData({
|
||||
isLoggedIn: false,
|
||||
userInfo: null,
|
||||
userIdShort: '',
|
||||
readCount: app.getReadCount(),
|
||||
readCount: guestReadCount,
|
||||
readCountText: formatStatNum(guestReadCount),
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: false,
|
||||
recentChapters: [],
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0
|
||||
matchHistory: 0,
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0'
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -182,15 +204,21 @@ Page({
|
||||
const recentChapters = Array.isArray(res.data.recentChapters)
|
||||
? res.data.recentChapters.map((item) => ({
|
||||
id: item.id,
|
||||
mid: item.mid || app.getSectionMid(item.id),
|
||||
mid: item.mid,
|
||||
title: item.title || `章节 ${item.id}`
|
||||
}))
|
||||
: []
|
||||
|
||||
const readCount = Number(res.data.readCount || 0)
|
||||
const totalReadTime = Number(res.data.totalReadMinutes || 0)
|
||||
const matchHistory = Number(res.data.matchHistory || 0)
|
||||
this.setData({
|
||||
readCount: Number(res.data.readCount || 0),
|
||||
totalReadTime: Number(res.data.totalReadMinutes || 0),
|
||||
matchHistory: Number(res.data.matchHistory || 0),
|
||||
readCount,
|
||||
totalReadTime,
|
||||
matchHistory,
|
||||
readCountText: formatStatNum(readCount),
|
||||
totalReadTimeText: formatStatNum(totalReadTime),
|
||||
matchHistoryText: formatStatNum(matchHistory),
|
||||
recentChapters
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -300,6 +328,7 @@ Page({
|
||||
|
||||
// 一键收款:逐条调起微信收款页(有上一页则返回,无则回首页)
|
||||
async handleOneClickReceive() {
|
||||
trackClick('my', 'btn_click', '一键收款')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
if (this.data.receivingAll) return
|
||||
|
||||
@@ -466,9 +495,8 @@ Page({
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 获取上传后的完整URL(OSS 返回完整 URL,本地返回相对路径)
|
||||
const rawUrl = uploadRes.data.url || ''
|
||||
const avatarUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://') ? rawUrl : app.globalData.baseUrl + rawUrl
|
||||
// 2. 获取上传后的完整URL
|
||||
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
|
||||
console.log('[My] 头像上传成功:', avatarUrl)
|
||||
|
||||
// 3. 更新本地头像
|
||||
@@ -643,7 +671,7 @@ Page({
|
||||
|
||||
// 显示登录弹窗(每次打开时协议未勾选,符合审核要求)
|
||||
showLogin() {
|
||||
trackClick('my', 'btn_click', '登录')
|
||||
trackClick('my', 'btn_click', '点击登录')
|
||||
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
@@ -691,7 +719,6 @@ Page({
|
||||
|
||||
// 微信登录(须已勾选同意协议,且做好错误处理避免审核报错)
|
||||
async handleWechatLogin() {
|
||||
trackClick('my', 'btn_click', '微信登录')
|
||||
if (!this.data.agreeProtocol) {
|
||||
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
|
||||
return
|
||||
@@ -744,20 +771,20 @@ Page({
|
||||
|
||||
// 点击菜单
|
||||
handleMenuTap(e) {
|
||||
trackClick('my', 'btn_click', e.currentTarget.dataset.id || '菜单')
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('my', 'nav_click', id || '菜单')
|
||||
|
||||
if (!this.data.isLoggedIn && id !== 'about') {
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
|
||||
const routes = {
|
||||
wallet: '/pages/wallet/wallet',
|
||||
orders: '/pages/purchases/purchases',
|
||||
giftPay: '/pages/gift-pay/list',
|
||||
referral: '/pages/referral/referral',
|
||||
withdrawRecords: '/pages/withdraw-records/withdraw-records',
|
||||
about: '/pages/about/about',
|
||||
wallet: '/pages/wallet/wallet',
|
||||
settings: '/pages/settings/settings'
|
||||
}
|
||||
|
||||
@@ -769,35 +796,32 @@ Page({
|
||||
// 跳转到阅读页(优先传 mid,与分享逻辑一致)
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
trackClick('my', 'card_click', id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
// 跳转到目录
|
||||
goToChapters() {
|
||||
trackClick('my', 'nav_click', '目录')
|
||||
trackClick('my', 'nav_click', '已读章节')
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
// 跳转到关于页
|
||||
goToAbout() {
|
||||
wx.navigateTo({ url: '/pages/about/about' })
|
||||
},
|
||||
|
||||
// 跳转到匹配
|
||||
goToMatch() {
|
||||
trackClick('my', 'nav_click', '匹配')
|
||||
trackClick('my', 'nav_click', '匹配伙伴')
|
||||
wx.switchTab({ url: '/pages/match/match' })
|
||||
},
|
||||
|
||||
// 跳转到推广中心(需登录)
|
||||
goToReferral() {
|
||||
trackClick('my', 'nav_click', '推广')
|
||||
trackClick('my', 'nav_click', '推广中心')
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
if (!this.data.referralEnabled) return
|
||||
wx.navigateTo({ url: '/pages/referral/referral' })
|
||||
},
|
||||
|
||||
@@ -816,46 +840,6 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
async loadWalletBalance() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
const userId = app.globalData.userInfo.id
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
|
||||
if (res && res.data) {
|
||||
this.setData({ walletBalance: (res.data.balance || 0).toFixed(2) })
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadGiftList() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
const userId = app.globalData.userInfo.id
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/balance/gifts?userId=${userId}`, silent: true })
|
||||
if (res?.success && res.data?.gifts) {
|
||||
this.setData({ giftList: res.data.gifts })
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
onGiftShareTap(e) {
|
||||
const giftCode = e.currentTarget.dataset.code
|
||||
const title = e.currentTarget.dataset.title || '精选文章'
|
||||
const sectionId = e.currentTarget.dataset.sectionId
|
||||
this._pendingGiftShare = { giftCode, title, sectionId }
|
||||
wx.showModal({
|
||||
title: '分享代付链接',
|
||||
content: `将「${title}」的免费阅读链接分享给好友`,
|
||||
confirmText: '立即分享',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (r.confirm) {
|
||||
wx.shareAppMessage()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// VIP状态查询(注意:hasFullBook=9.9 买断,不等同 VIP)
|
||||
async loadVipStatus() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
@@ -879,6 +863,18 @@ Page({
|
||||
} catch (e) { console.log('[My] VIP查询失败', e) }
|
||||
},
|
||||
|
||||
async loadWalletBalance() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
|
||||
if (res?.success && res.data) {
|
||||
const balance = res.data.balance || 0
|
||||
this.setData({ walletBalanceText: balance.toFixed(2) })
|
||||
}
|
||||
} catch (e) { console.log('[My] 余额查询失败', e) }
|
||||
},
|
||||
|
||||
// 头像点击:已登录弹出选项(微信头像 / 相册)
|
||||
onAvatarTap() {
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
@@ -918,8 +914,7 @@ Page({
|
||||
fail: (e) => reject(e)
|
||||
})
|
||||
})
|
||||
const rawAvatarUrl = uploadRes.data.url || ''
|
||||
const avatarUrl = rawAvatarUrl.startsWith('http://') || rawAvatarUrl.startsWith('https://') ? rawAvatarUrl : app.globalData.baseUrl + rawAvatarUrl
|
||||
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
|
||||
const userInfo = this.data.userInfo
|
||||
userInfo.avatar = avatarUrl
|
||||
this.setData({ userInfo })
|
||||
@@ -937,18 +932,25 @@ Page({
|
||||
},
|
||||
|
||||
goToVip() {
|
||||
trackClick('my', 'nav_click', 'VIP')
|
||||
trackClick('my', 'btn_click', '会员中心')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
wx.navigateTo({ url: '/pages/vip/vip' })
|
||||
},
|
||||
|
||||
// 进入个人资料编辑页(stitch_soul)
|
||||
goToProfileEdit() {
|
||||
trackClick('my', 'nav_click', '设置')
|
||||
trackClick('my', 'nav_click', '资料编辑')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
},
|
||||
|
||||
// 进入个人资料展示页(enhanced_professional_profile),展示页内可再进编辑
|
||||
goToProfileShow() {
|
||||
trackClick('my', 'btn_click', '编辑')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
|
||||
},
|
||||
|
||||
async handleWithdraw() {
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
const amount = parseFloat(this.data.pendingEarnings)
|
||||
@@ -1047,17 +1049,6 @@ Page({
|
||||
stopPropagation() {},
|
||||
|
||||
onShareAppMessage() {
|
||||
if (this._pendingGiftShare) {
|
||||
const { giftCode, title, sectionId } = this._pendingGiftShare
|
||||
this._pendingGiftShare = null
|
||||
const ref = app.getMyReferralCode()
|
||||
let path = `/pages/read/read?id=${sectionId}&gift=${giftCode}`
|
||||
if (ref) path += `&ref=${ref}`
|
||||
return {
|
||||
title: `🎁 好友已为你解锁:${title}`,
|
||||
path
|
||||
}
|
||||
}
|
||||
const ref = app.getMyReferralCode()
|
||||
return {
|
||||
title: 'Soul创业派对 - 我的',
|
||||
|
||||
@@ -34,9 +34,15 @@
|
||||
<view class="profile-meta">
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||
<view class="profile-name-actions">
|
||||
<view class="profile-edit-btn" bindtap="goToProfileShow">
|
||||
<image class="profile-edit-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
|
||||
<text class="profile-edit-text">编辑</text>
|
||||
</view>
|
||||
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="vip-tags">
|
||||
<view class="vip-tags" wx:if="{{!auditMode}}">
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
|
||||
@@ -46,19 +52,19 @@
|
||||
</view>
|
||||
<view class="profile-stats-row">
|
||||
<view class="profile-stat" bindtap="goToChapters">
|
||||
<text class="profile-stat-val">{{readCount}}</text>
|
||||
<text class="profile-stat-val">{{readCountText}}</text>
|
||||
<text class="profile-stat-label">已读章节</text>
|
||||
</view>
|
||||
<view class="profile-stat" bindtap="goToReferral">
|
||||
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
|
||||
<text class="profile-stat-val">{{referralCount}}</text>
|
||||
<text class="profile-stat-label">推荐好友</text>
|
||||
</view>
|
||||
<view class="profile-stat" bindtap="goToReferral">
|
||||
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
|
||||
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
|
||||
<text class="profile-stat-label">我的收益</text>
|
||||
</view>
|
||||
<view class="profile-stat" bindtap="handleMenuTap" data-id="wallet">
|
||||
<text class="profile-stat-val">{{walletBalance > 0 ? '¥' + walletBalance : '0'}}</text>
|
||||
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
|
||||
<text class="profile-stat-val">{{walletBalanceText}}</text>
|
||||
<text class="profile-stat-label">我的余额</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -67,8 +73,8 @@
|
||||
|
||||
<!-- 已登录:内容区 -->
|
||||
<view class="main-content" wx:if="{{isLoggedIn}}">
|
||||
<!-- 一键收款(仅在有待确认收款时显示) -->
|
||||
<view class="card receive-card" wx:if="{{pendingConfirmList.length > 0}}">
|
||||
<!-- 一键收款(仅在有待确认收款时显示;审核模式隐藏) -->
|
||||
<view class="card receive-card" wx:if="{{pendingConfirmList.length > 0 && !auditMode}}">
|
||||
<view class="receive-top">
|
||||
<view class="receive-left">
|
||||
<view class="receive-title-row">
|
||||
@@ -85,7 +91,10 @@
|
||||
</view>
|
||||
<view class="receive-bottom">
|
||||
<text class="receive-tip">将依次调起微信收款页完成领取</text>
|
||||
<text class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">查看提现记录 ›</text>
|
||||
<view class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">
|
||||
<text>查看提现记录</text>
|
||||
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.6)" customClass="receive-link-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -98,17 +107,17 @@
|
||||
<view class="stats-grid">
|
||||
<view class="stat-box" bindtap="goToChapters">
|
||||
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{readCount}}</text>
|
||||
<text class="stat-num">{{readCountText}}</text>
|
||||
<text class="stat-label">已读章节</text>
|
||||
</view>
|
||||
<view class="stat-box" bindtap="goToChapters">
|
||||
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{totalReadTime}}</text>
|
||||
<text class="stat-num">{{totalReadTimeText}}</text>
|
||||
<text class="stat-label">阅读分钟</text>
|
||||
</view>
|
||||
<view class="stat-box" bindtap="goToMatch">
|
||||
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{matchHistory}}</text>
|
||||
<text class="stat-num">{{matchHistoryText}}</text>
|
||||
<text class="stat-label">匹配伙伴</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -138,52 +147,32 @@
|
||||
</view>
|
||||
<view class="recent-empty" wx:else>
|
||||
<text class="recent-empty-text">暂无阅读记录</text>
|
||||
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 →</view>
|
||||
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 <icon name="chevron-right" size="24" color="#00CED1" customClass="recent-empty-arrow"></icon></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的代付链接 -->
|
||||
<view class="card gift-card" wx:if="{{giftList.length > 0}}">
|
||||
<view class="card-header">
|
||||
<image class="card-icon-img" src="/assets/icons/wallet.svg" mode="aspectFit"/>
|
||||
<text class="card-title">我的代付链接</text>
|
||||
</view>
|
||||
<view class="gift-list">
|
||||
<view class="gift-item" wx:for="{{giftList}}" wx:key="giftCode">
|
||||
<view class="gift-left">
|
||||
<text class="gift-title">{{item.sectionTitle}}</text>
|
||||
<text class="gift-meta">¥{{item.amount}} · {{item.status === 'pending' ? '待领取' : '已领取'}} · {{item.createdAt}}</text>
|
||||
</view>
|
||||
<view class="gift-action" wx:if="{{item.status === 'pending'}}" bindtap="onGiftShareTap" data-code="{{item.giftCode}}" data-title="{{item.sectionTitle}}" data-section-id="{{item.sectionId}}">
|
||||
<text class="gift-share-btn">分享</text>
|
||||
</view>
|
||||
<text class="gift-done" wx:else>已送出</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的订单 + 关于作者 + 设置 -->
|
||||
<!-- 我的订单 + 设置 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon-wrap icon-teal"><image class="menu-icon-img" src="/assets/icons/folder-teal.svg" mode="aspectFit"/></view>
|
||||
<text class="menu-text">我的订单</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="giftPay">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon-wrap icon-blue"><image class="menu-icon-img" src="/assets/icons/info-blue.svg" mode="aspectFit"/></view>
|
||||
<text class="menu-text">关于作者</text>
|
||||
<view class="menu-icon-wrap icon-gold"><image class="menu-icon-img" src="/assets/icons/gift.svg" mode="aspectFit"/></view>
|
||||
<text class="menu-text">我的代付</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
|
||||
<view class="menu-item" wx:if="{{showSettingsEntry}}" bindtap="handleMenuTap" data-id="settings">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
|
||||
<text class="menu-text">设置</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -191,8 +180,8 @@
|
||||
<!-- 登录弹窗 -->
|
||||
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
|
||||
<view class="modal-content login-modal-content" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeLoginModal">✕</view>
|
||||
<view class="login-icon">🔐</view>
|
||||
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="login-title">登录 Soul创业派对</text>
|
||||
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
|
||||
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{isLoggingIn || !agreeProtocol}}">
|
||||
@@ -201,7 +190,7 @@
|
||||
</button>
|
||||
<view class="login-modal-cancel" bindtap="closeLoginModal">取消</view>
|
||||
<view class="login-agree-row" catchtap="toggleAgree">
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}">{{agreeProtocol ? '✓' : ''}}</view>
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
|
||||
<text class="agree-text">我已阅读并同意</text>
|
||||
<text class="agree-link" catchtap="openUserProtocol">《用户协议》</text>
|
||||
<text class="agree-text">和</text>
|
||||
@@ -218,14 +207,14 @@
|
||||
<view class="form-input-wrap">
|
||||
<text class="form-label">手机号</text>
|
||||
<view class="form-input-inner">
|
||||
<text class="form-icon">📱</text>
|
||||
<icon name="smartphone" size="36" color="#8e8e93" customClass="form-icon"></icon>
|
||||
<input class="form-input" type="tel" placeholder="请输入您的手机号" value="{{contactPhone}}" bindinput="onContactPhoneInput"/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-input-wrap">
|
||||
<text class="form-label">微信号</text>
|
||||
<view class="form-input-inner">
|
||||
<text class="form-icon">💬</text>
|
||||
<icon name="message-circle" size="36" color="#8e8e93" customClass="form-icon"></icon>
|
||||
<input class="form-input" type="text" placeholder="请输入您的微信号" value="{{contactWechat}}" bindinput="onContactWechatInput"/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -237,7 +226,7 @@
|
||||
<!-- 头像弹窗:必须点击 button 才能获取微信头像(隐私规范) -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<text class="avatar-modal-title">获取微信头像</text>
|
||||
<text class="avatar-modal-desc">点击下方按钮使用你的微信头像</text>
|
||||
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
|
||||
@@ -248,9 +237,9 @@
|
||||
<!-- 修改昵称弹窗 -->
|
||||
<view class="modal-overlay" wx:if="{{showNicknameModal}}" bindtap="closeNicknameModal">
|
||||
<view class="modal-content nickname-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeNicknameModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeNicknameModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<view class="modal-header">
|
||||
<text class="modal-icon">✏️</text>
|
||||
<icon name="pencil" size="48" color="#00CED1" customClass="modal-icon"></icon>
|
||||
<text class="modal-title">修改昵称</text>
|
||||
</view>
|
||||
<view class="nickname-input-wrap">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--隐私政策页 - 审核要求可点击查看-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">←</view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">隐私政策</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 资料编辑 - comprehensive_profile_editor_v1_1 | input/textarea 用 view 包裹,配色 enhanced -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">编辑资料</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
@@ -11,7 +11,7 @@
|
||||
<scroll-view wx:else class="scroll-main" scroll-y>
|
||||
<!-- 温馨提示 -->
|
||||
<view class="tip-card">
|
||||
<text class="tip-icon">ℹ</text>
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">温馨提示:需完善手机号和微信号才能使用提现和找伙伴功能</text>
|
||||
</view>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera">📷</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
<text class="avatar-change">更换头像</text>
|
||||
</view>
|
||||
@@ -55,7 +55,7 @@
|
||||
<text class="form-label">地区</text>
|
||||
<view class="form-input-wrap form-input-suffix">
|
||||
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
|
||||
<text class="form-suffix">📍</text>
|
||||
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -80,7 +80,7 @@
|
||||
<!-- 核心联系方式 -->
|
||||
<view class="section">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">📞</text>
|
||||
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>核心联系方式</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
@@ -96,7 +96,7 @@
|
||||
<!-- 个人故事(仅 VIP 展示) -->
|
||||
<view class="section" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">💡</text>
|
||||
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>个人故事</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
@@ -116,7 +116,7 @@
|
||||
<!-- 互助需求(VIP 或 资源对接已填写时展示) -->
|
||||
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">🤝</text>
|
||||
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>互助需求</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
@@ -132,7 +132,7 @@
|
||||
<!-- 项目介绍(仅 VIP 展示) -->
|
||||
<view class="section" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">🚀</text>
|
||||
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>项目介绍</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
@@ -149,7 +149,7 @@
|
||||
<!-- 头像弹窗:通过 button 获取微信头像 -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<text class="avatar-modal-title">使用微信头像</text>
|
||||
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
|
||||
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 个人资料展示页 - enhanced_professional_profile 1:1 重构 -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">个人资料</text>
|
||||
<view class="nav-right" bindtap="goToEdit"><text class="nav-more">⋯</text></view>
|
||||
</view>
|
||||
@@ -20,7 +20,7 @@
|
||||
<text class="hero-name">{{profile.nickname || '未设置昵称'}}</text>
|
||||
<view class="hero-tags">
|
||||
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
|
||||
<text class="tag tag-region" wx:if="{{profile.region}}">📍 {{profile.region}}</text>
|
||||
<view class="tag tag-region" wx:if="{{profile.region}}"><icon name="map-pin" size="24" color="currentColor" customClass="tag-icon"></icon><text>{{profile.region}}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- 基本信息 -->
|
||||
<view class="section">
|
||||
<view class="section-head">
|
||||
<text class="section-icon">👤</text>
|
||||
<icon name="user" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text class="section-title">基本信息</text>
|
||||
</view>
|
||||
<view class="section-body">
|
||||
@@ -72,22 +72,22 @@
|
||||
<!-- 个人故事 -->
|
||||
<view class="section" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
|
||||
<view class="section-head">
|
||||
<text class="section-icon section-icon-yellow">💡</text>
|
||||
<icon name="lightbulb" size="40" color="#FFD700" customClass="section-icon section-icon-yellow"></icon>
|
||||
<text class="section-title">个人故事</text>
|
||||
</view>
|
||||
<view class="section-body">
|
||||
<view class="story-block" wx:if="{{profile.storyBestMonth}}">
|
||||
<view class="story-head"><text class="story-emoji">🏆</text><text class="story-label">最赚钱的一个月做的是什么</text></view>
|
||||
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最赚钱的一个月做的是什么</text></view>
|
||||
<text class="story-text">{{profile.storyBestMonth}}</text>
|
||||
</view>
|
||||
<view class="field-divider" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
|
||||
<view class="story-block" wx:if="{{profile.storyAchievement}}">
|
||||
<view class="story-head"><text class="story-emoji">⭐</text><text class="story-label">最有成就感的一件事</text></view>
|
||||
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最有成就感的一件事</text></view>
|
||||
<text class="story-text">{{profile.storyAchievement}}</text>
|
||||
</view>
|
||||
<view class="field-divider" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
|
||||
<view class="story-block" wx:if="{{profile.storyTurning}}">
|
||||
<view class="story-head"><text class="story-emoji">🔄</text><text class="story-label">人生的转折点</text></view>
|
||||
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">人生的转折点</text></view>
|
||||
<text class="story-text">{{profile.storyTurning}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -96,7 +96,7 @@
|
||||
<!-- 互助需求 -->
|
||||
<view class="section" wx:if="{{profile.helpOffer || profile.helpNeed}}">
|
||||
<view class="section-head">
|
||||
<text class="section-icon">🤝</text>
|
||||
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text class="section-title">互助需求</text>
|
||||
</view>
|
||||
<view class="section-body">
|
||||
@@ -114,7 +114,7 @@
|
||||
<!-- 项目介绍 -->
|
||||
<view class="section" wx:if="{{profile.projectIntro}}">
|
||||
<view class="section-head">
|
||||
<text class="section-icon">🚀</text>
|
||||
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text class="section-title">项目介绍</text>
|
||||
</view>
|
||||
<view class="section-body">
|
||||
@@ -129,7 +129,7 @@
|
||||
<view class="bottom-bar">
|
||||
<view class="vip-btn-outline" bindtap="goToVip">
|
||||
<text>成为超级个体</text>
|
||||
<text class="vip-btn-arrow">→</text>
|
||||
<icon name="chevron-right" size="36" color="#00CED1" customClass="vip-btn-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
|
||||
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
|
||||
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
|
||||
.tag-region { background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
|
||||
.tag-region { display: flex; align-items: center; gap: 8rpx; background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
|
||||
.tag-region .tag-icon { flex-shrink: 0; }
|
||||
|
||||
/* 通用区块 */
|
||||
.section {
|
||||
|
||||
@@ -40,7 +40,7 @@ Page({
|
||||
const orders = purchasedSections.map((id, index) => ({
|
||||
id: `order_${index}`,
|
||||
sectionId: id,
|
||||
sectionMid: app.getSectionMid(id),
|
||||
sectionMid: 0,
|
||||
title: `章节 ${id}`,
|
||||
amount: 1,
|
||||
status: 'completed',
|
||||
@@ -52,7 +52,7 @@ Page({
|
||||
const purchasedSections = app.globalData.purchasedSections || []
|
||||
this.setData({
|
||||
orders: purchasedSections.map((id, i) => ({
|
||||
id: `order_${i}`, sectionId: id, sectionMid: app.getSectionMid(id), title: `章节 ${id}`, amount: 1, status: 'completed',
|
||||
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
|
||||
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--订单页-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">←</view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">我的订单</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
@@ -28,7 +28,7 @@
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-icon">📦</text>
|
||||
<icon name="package" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
|
||||
<text class="empty-text">暂无订单</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
* - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
|
||||
*/
|
||||
|
||||
const accessManager = require('../../utils/chapterAccessManager')
|
||||
const readingTracker = require('../../utils/readingTracker')
|
||||
import accessManager from '../../utils/chapterAccessManager'
|
||||
import readingTracker from '../../utils/readingTracker'
|
||||
const { parseScene } = require('../../utils/scene.js')
|
||||
const contentParser = require('../../utils/contentParser.js')
|
||||
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
const app = getApp()
|
||||
|
||||
Page({
|
||||
@@ -63,62 +62,78 @@ Page({
|
||||
// 价格
|
||||
sectionPrice: 1,
|
||||
fullBookPrice: 9.9,
|
||||
totalSections: 0,
|
||||
totalSections: 62,
|
||||
|
||||
// 弹窗
|
||||
showShareModal: false,
|
||||
showGiftModal: false,
|
||||
giftQuantity: 1,
|
||||
showLoginModal: false,
|
||||
agreeProtocol: false,
|
||||
showPosterModal: false,
|
||||
isPaying: false,
|
||||
isGeneratingPoster: false,
|
||||
showShareTip: false,
|
||||
_shareTipShown: false,
|
||||
_lastScrollTop: 0,
|
||||
|
||||
// 章节 mid(扫码/海报分享用,便于分享 path 带 mid)
|
||||
sectionMid: null
|
||||
sectionMid: null,
|
||||
|
||||
// 余额(用于余额支付)
|
||||
walletBalance: 0,
|
||||
|
||||
// 审核模式:隐藏购买按钮
|
||||
auditMode: false,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
},
|
||||
|
||||
async onLoad(options) {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
|
||||
|
||||
// 预加载 linkTags、linkedMiniprograms、persons(供 onLinkTagTap / onMentionTap 和内容自动匹配用)
|
||||
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms || !app.globalData.personsConfig) {
|
||||
try {
|
||||
const cfg = await app.request({ url: '/api/miniprogram/config', silent: true })
|
||||
if (cfg) {
|
||||
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
|
||||
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
|
||||
if (Array.isArray(cfg.persons)) app.globalData.personsConfig = cfg.persons
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
// 预加载:core+auditMode(getConfig)+ read-extras 懒加载(linkTags、linkedMiniprograms)
|
||||
Promise.all([
|
||||
app.getConfig(),
|
||||
app.getReadExtras()
|
||||
]).then(([cfg, extras]) => {
|
||||
if (cfg) {
|
||||
const mp = (cfg && cfg.mpConfig) || {}
|
||||
const auditMode = !!mp.auditMode
|
||||
app.globalData.auditMode = auditMode
|
||||
if (typeof this.setData === 'function') this.setData({ auditMode })
|
||||
}
|
||||
if (extras && Array.isArray(extras.linkTags)) {
|
||||
app.globalData.linkTagsConfig = extras.linkTags
|
||||
app.globalData.linkedMiniprograms = extras.linkedMiniprograms || []
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
// 支持 scene(扫码)、mid、id、ref
|
||||
// 支持 scene(扫码)、mid、id、ref、gift(代付)
|
||||
const sceneStr = (options && options.scene) || ''
|
||||
const parsed = parseScene(sceneStr)
|
||||
const ref = options.ref || parsed.ref
|
||||
const isGift = options.gift === '1' || options.gift === 'true'
|
||||
// 代付统一到代付页:gift=1&ref=requestSn 时直接跳转,禁止在阅读页代付
|
||||
if (isGift && ref) {
|
||||
wx.redirectTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(ref)}` })
|
||||
return
|
||||
}
|
||||
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
|
||||
let id = options.id || parsed.id || app.globalData.initialSectionId
|
||||
const ref = options.ref || parsed.ref
|
||||
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
|
||||
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
|
||||
|
||||
// mid 有值但无 id 时,从 bookData 或 API 解析 id
|
||||
console.log("页面:",mid);
|
||||
|
||||
// 兼容:mid 有值但无 id 时,用 by-mid 解析 id;有 id 无 mid 时,后续用 by-id 请求
|
||||
if (mid && !id) {
|
||||
const bookData = app.globalData.bookData || []
|
||||
const ch = bookData.find(c => c.mid == mid || (c.mid && Number(c.mid) === Number(mid)))
|
||||
if (ch?.id) {
|
||||
id = ch.id
|
||||
} else {
|
||||
try {
|
||||
const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}`
|
||||
const uid = app.globalData.userInfo?.id
|
||||
const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true })
|
||||
if (chRes && chRes.id) id = chRes.id
|
||||
} catch (e) {
|
||||
console.warn('[Read] by-mid 解析失败:', e)
|
||||
}
|
||||
try {
|
||||
const resolveUrl = `/api/miniprogram/book/chapter/by-mid/${mid}`
|
||||
const uid = app.globalData.userInfo?.id
|
||||
const chRes = await app.request({ url: uid ? resolveUrl + '?userId=' + encodeURIComponent(uid) : resolveUrl, silent: true })
|
||||
if (chRes && chRes.id) id = chRes.id
|
||||
} catch (e) {
|
||||
console.warn('[Read] by-mid 解析失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +158,6 @@ Page({
|
||||
app.handleReferralCode({ query: { ref } })
|
||||
}
|
||||
|
||||
const giftCode = options.gift || ''
|
||||
if (giftCode) {
|
||||
this._pendingGiftCode = giftCode
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await accessManager.fetchLatestConfig()
|
||||
this.setData({
|
||||
@@ -170,35 +180,13 @@ Page({
|
||||
// 加载内容(复用已拉取的章节数据,避免二次请求)
|
||||
await this.loadContent(id, accessState, chapterRes)
|
||||
|
||||
// 自动领取礼物码(代付解锁)
|
||||
if (this._pendingGiftCode && !canAccess && app.globalData.isLoggedIn) {
|
||||
await this._redeemGiftCode(this._pendingGiftCode)
|
||||
this._pendingGiftCode = null
|
||||
return
|
||||
}
|
||||
|
||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
readingTracker.init(id)
|
||||
}
|
||||
|
||||
// 5. 加载导航
|
||||
this.loadNavigation(id)
|
||||
|
||||
// 6. 规则引擎:阅读前检查(填头像、绑手机等)
|
||||
checkAndExecute('before_read', this)
|
||||
|
||||
// 7. 记录浏览行为到 user_tracks
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
app.request('/api/miniprogram/track', {
|
||||
method: 'POST',
|
||||
data: { userId, action: 'view_chapter', target: id, extraData: { sectionId: id, mid: mid || '' } },
|
||||
silent: true
|
||||
}).catch(() => {})
|
||||
// 更新全局阅读计数
|
||||
app.globalData.readCount = (app.globalData.readCount || 0) + 1
|
||||
}
|
||||
// 5. 导航:文章详情已带 prev/next
|
||||
this._applyPrevNext(chapterRes)
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Read] 初始化失败:', e)
|
||||
@@ -216,11 +204,6 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
const currentScrollTop = e.scrollTop || 0
|
||||
const lastScrollTop = this.data._lastScrollTop || 0
|
||||
const isScrollingDown = currentScrollTop < lastScrollTop
|
||||
this.setData({ _lastScrollTop: currentScrollTop })
|
||||
|
||||
// 获取滚动信息并更新追踪器
|
||||
const query = wx.createSelectorQuery()
|
||||
query.select('.page').boundingClientRect()
|
||||
@@ -239,12 +222,6 @@ Page({
|
||||
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
|
||||
: 0
|
||||
this.setData({ readingProgress: progress })
|
||||
|
||||
// 阅读超过20%且向上滑动时,弹出一次分享提示
|
||||
if (progress >= 20 && isScrollingDown && !this.data._shareTipShown) {
|
||||
this.setData({ showShareTip: true, _shareTipShown: true })
|
||||
setTimeout(() => { this.setData({ showShareTip: false }) }, 4000)
|
||||
}
|
||||
|
||||
// 更新阅读追踪器(记录最大进度、判断是否读完)
|
||||
readingTracker.updateProgress(scrollInfo)
|
||||
@@ -272,8 +249,7 @@ Page({
|
||||
// 已解锁用 data.content(完整内容),未解锁用 content(预览);先 determineAccessState 再 loadContent 保证顺序正确
|
||||
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
|
||||
if (res && displayContent) {
|
||||
const parserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] }
|
||||
const { lines, segments } = contentParser.parseContent(displayContent, parserConfig)
|
||||
const { lines, segments } = contentParser.parseContent(displayContent)
|
||||
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
||||
const previewCount = lines.length
|
||||
const updates = {
|
||||
@@ -297,8 +273,7 @@ Page({
|
||||
try {
|
||||
const cached = wx.getStorageSync(cacheKey)
|
||||
if (cached && cached.content) {
|
||||
const cachedParserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] }
|
||||
const { lines, segments } = contentParser.parseContent(cached.content, cachedParserConfig)
|
||||
const { lines, segments } = contentParser.parseContent(cached.content)
|
||||
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
||||
const previewCount = lines.length
|
||||
this.setData({
|
||||
@@ -321,34 +296,52 @@ Page({
|
||||
|
||||
// 获取章节信息
|
||||
getSectionInfo(id) {
|
||||
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
|
||||
if (cachedSection) {
|
||||
return {
|
||||
id,
|
||||
title: cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`,
|
||||
isFree: cachedSection.isFree === true || cachedSection.is_free === true || cachedSection.price === 0,
|
||||
price: cachedSection.price ?? 1
|
||||
}
|
||||
// 特殊章节
|
||||
if (id === 'preface') {
|
||||
return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 }
|
||||
}
|
||||
|
||||
if (id === 'epilogue') {
|
||||
return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 }
|
||||
}
|
||||
if (id.startsWith('appendix')) {
|
||||
const appendixTitles = {
|
||||
'appendix-1': 'Soul派对房精选对话',
|
||||
'appendix-2': '创业者自检清单',
|
||||
'appendix-3': '本书提到的工具和资源'
|
||||
}
|
||||
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
|
||||
}
|
||||
|
||||
// 普通章节
|
||||
return {
|
||||
id,
|
||||
id: id,
|
||||
title: this.getSectionTitle(id),
|
||||
isFree: false,
|
||||
isFree: id === '1.1',
|
||||
price: 1
|
||||
}
|
||||
},
|
||||
|
||||
// 获取章节标题
|
||||
getSectionTitle(id) {
|
||||
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
|
||||
if (cachedSection) {
|
||||
return cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`
|
||||
const titles = {
|
||||
'1.1': '荷包:电动车出租的被动收入模式',
|
||||
'1.2': '老墨:资源整合高手的社交方法',
|
||||
'1.3': '笑声背后的MBTI',
|
||||
'1.4': '人性的三角结构:利益、情感、价值观',
|
||||
'1.5': '沟通差的问题:为什么你说的别人听不懂',
|
||||
'2.1': '相亲故事:你以为找的是人,实际是在找模式',
|
||||
'2.2': '找工作迷茫者:为什么简历解决不了人生',
|
||||
'2.3': '撸运费险:小钱困住大脑的真实心理',
|
||||
'2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力',
|
||||
'2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒',
|
||||
'3.1': '3000万流水如何跑出来(退税模式解析)',
|
||||
'8.1': '流量杠杆:抖音、Soul、飞书',
|
||||
'9.14': '大健康私域:一个月150万的70后'
|
||||
}
|
||||
return `章节 ${id}`
|
||||
return titles[id] || `章节 ${id}`
|
||||
},
|
||||
|
||||
// 根据 id/mid 构造章节接口路径(优先使用 mid)。必须带 userId 才能让后端正确判断付费用户并返回完整内容
|
||||
// 根据 id/mid 构造章节接口路径:优先 mid(by-mid),否则用 id(by-id,兼容旧链接)
|
||||
_getChapterUrl(params = {}) {
|
||||
const { id, mid } = params
|
||||
const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid
|
||||
@@ -357,7 +350,7 @@ Page({
|
||||
url = `/api/miniprogram/book/chapter/by-mid/${finalMid}`
|
||||
} else {
|
||||
const finalId = id || this.data.sectionId
|
||||
url = `/api/miniprogram/book/chapter/${finalId}`
|
||||
url = `/api/miniprogram/book/chapter/by-id/${encodeURIComponent(finalId)}`
|
||||
}
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) url += (url.includes('?') ? '&' : '?') + 'userId=' + encodeURIComponent(userId)
|
||||
@@ -452,47 +445,21 @@ Page({
|
||||
},
|
||||
|
||||
|
||||
// 加载导航:基于后端章节顺序计算上一篇/下一篇
|
||||
async loadNavigation(id) {
|
||||
try {
|
||||
// 优先使用全局缓存的 bookData
|
||||
let chapters = app.globalData.bookData || []
|
||||
if (!chapters || !Array.isArray(chapters) || chapters.length === 0) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
chapters = (res && (res.data || res.chapters)) || []
|
||||
}
|
||||
if (!chapters || chapters.length === 0) {
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
return
|
||||
}
|
||||
// 过滤掉没有 id 的记录,并按 sort_order + id 排序
|
||||
const ordered = chapters
|
||||
.filter(c => c.id)
|
||||
.sort((a, b) => {
|
||||
const soA = typeof a.sort_order === 'number' ? a.sort_order : (typeof a.sortOrder === 'number' ? a.sortOrder : 0)
|
||||
const soB = typeof b.sort_order === 'number' ? b.sort_order : (typeof b.sortOrder === 'number' ? b.sortOrder : 0)
|
||||
if (soA !== soB) return soA - soB
|
||||
return String(a.id).localeCompare(String(b.id), 'zh-Hans-CN')
|
||||
})
|
||||
const index = ordered.findIndex(c => String(c.id) === String(id))
|
||||
const prev = index > 0 ? ordered[index - 1] : null
|
||||
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null
|
||||
this.setData({
|
||||
prevSection: prev ? {
|
||||
id: prev.id,
|
||||
mid: prev.mid ?? prev.MID ?? null,
|
||||
title: prev.section_title || prev.sectionTitle || prev.title || this.getSectionTitle(prev.id),
|
||||
} : null,
|
||||
nextSection: next ? {
|
||||
id: next.id,
|
||||
mid: next.mid ?? next.MID ?? null,
|
||||
title: next.section_title || next.sectionTitle || next.title || this.getSectionTitle(next.id),
|
||||
} : null,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[Read] loadNavigation failed:', e)
|
||||
this.setData({ prevSection: null, nextSection: null })
|
||||
}
|
||||
_applyPrevNext(res) {
|
||||
const prev = res?.prev
|
||||
const next = res?.next
|
||||
this.setData({
|
||||
prevSection: prev ? {
|
||||
id: prev.id,
|
||||
mid: prev.mid ?? null,
|
||||
title: prev.title || this.getSectionTitle(prev.id),
|
||||
} : null,
|
||||
nextSection: next ? {
|
||||
id: next.id,
|
||||
mid: next.mid ?? null,
|
||||
title: next.title || this.getSectionTitle(next.id),
|
||||
} : null,
|
||||
})
|
||||
},
|
||||
|
||||
// 返回(从分享进入无栈时回首页)
|
||||
@@ -519,53 +486,33 @@ Page({
|
||||
}
|
||||
}
|
||||
|
||||
// CKB 类型:走「链接卡若」留资流程(与首页 onLinkKaruo 一致)
|
||||
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
|
||||
if (tagType === 'ckb') {
|
||||
this._doCkbLead(label)
|
||||
// 触发通用加好友(无特定 personId,使用全局 CKB Key)
|
||||
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
|
||||
return
|
||||
}
|
||||
|
||||
// 小程序类型:先查 linkedMiniprograms 得 appId,降级直接用 mpKey/appId 字段
|
||||
// 小程序类型:用密钥查 linkedMiniprograms 得 appId,再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
|
||||
if (tagType === 'miniprogram') {
|
||||
let appId = (e.currentTarget.dataset.appId || '').trim()
|
||||
if (!mpKey && label) {
|
||||
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
|
||||
if (cached) {
|
||||
mpKey = cached.mpKey || ''
|
||||
if (!appId && cached.appId) appId = cached.appId
|
||||
}
|
||||
if (cached) mpKey = cached.mpKey || ''
|
||||
}
|
||||
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
|
||||
const targetAppId = (linked && linked.appId) ? linked.appId : (appId || mpKey || '')
|
||||
const selfAppId = (app.globalData.config?.mpConfig?.appId || app.globalData.appId || 'wxb8bbb2b10dec74aa')
|
||||
const targetPath = pagePath || (linked && linked.path) || ''
|
||||
if (targetAppId === selfAppId || !targetAppId) {
|
||||
if (targetPath) {
|
||||
const navPath = targetPath.startsWith('/') ? targetPath : '/' + targetPath
|
||||
wx.navigateTo({ url: navPath, fail: () => wx.switchTab({ url: navPath }) })
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (targetAppId) {
|
||||
if (linked && linked.appId) {
|
||||
wx.navigateToMiniProgram({
|
||||
appId: targetAppId,
|
||||
path: targetPath,
|
||||
appId: linked.appId,
|
||||
path: pagePath || linked.path || '',
|
||||
envVersion: 'release',
|
||||
success: () => {},
|
||||
fail: (err) => {
|
||||
console.warn('[LinkTag] 小程序跳转失败:', err)
|
||||
if (targetPath) {
|
||||
wx.navigateTo({ url: targetPath.startsWith('/') ? targetPath : '/' + targetPath, fail: () => {} })
|
||||
} else {
|
||||
wx.showToast({ title: '跳转失败,请检查小程序配置', icon: 'none' })
|
||||
}
|
||||
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: '未配置关联小程序', icon: 'none' })
|
||||
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
|
||||
}
|
||||
|
||||
// 小程序内部路径(pagePath 或 url 以 /pages/ 开头)
|
||||
@@ -597,17 +544,9 @@ Page({
|
||||
|
||||
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资
|
||||
onMentionTap(e) {
|
||||
let userId = e.currentTarget.dataset.userId
|
||||
const userId = e.currentTarget.dataset.userId
|
||||
const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA'
|
||||
if (!userId && nickname !== 'TA') {
|
||||
const persons = app.globalData.personsConfig || []
|
||||
const match = persons.find(p => p.name === nickname || (p.aliases || '').split(',').map(a => a.trim()).includes(nickname))
|
||||
if (match) userId = match.personId || ''
|
||||
}
|
||||
if (!userId) {
|
||||
wx.showToast({ title: `暂无 @${nickname} 的信息`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!userId) return
|
||||
wx.showModal({
|
||||
title: '添加好友',
|
||||
content: `是否添加 @${nickname} ?`,
|
||||
@@ -638,21 +577,19 @@ Page({
|
||||
const myUserId = app.globalData.userInfo.id
|
||||
let phone = (app.globalData.userInfo.phone || '').trim()
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
|
||||
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
|
||||
if (!phone && !wechatId) {
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
phone = (profileRes.data.phone || '').trim()
|
||||
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
|
||||
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if ((!phone && !wechatId) || !avatar) {
|
||||
if (!phone && !wechatId) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
|
||||
content: '请先填写手机号或微信号,以便对方联系您',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
@@ -661,6 +598,12 @@ Page({
|
||||
})
|
||||
return
|
||||
}
|
||||
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
@@ -678,84 +621,8 @@ Page({
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
const who = targetNickname || '对方'
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: `${who} 会主动添加你微信,请注意你的微信消息`,
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async _doCkbLead(label) {
|
||||
const app = getApp()
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再链接',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo.id
|
||||
let phone = (app.globalData.userInfo.phone || '').trim()
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
|
||||
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
|
||||
if (!phone && !wechatId) {
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
phone = (profileRes.data.phone || '').trim()
|
||||
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
|
||||
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if ((!phone && !wechatId) || !avatar) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/index-lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
phone: phone || undefined,
|
||||
wechatId: wechatId || undefined,
|
||||
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
|
||||
source: 'article_ckb_tag',
|
||||
tagLabel: label || undefined
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: '卡若会主动添加你微信,请注意你的微信消息',
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
@@ -774,6 +641,20 @@ Page({
|
||||
this.setData({ showShareModal: false })
|
||||
},
|
||||
|
||||
// 代付分享:直接跳转代付页,在代付页输入数量并支付(简化流程)
|
||||
showGiftShareModal() {
|
||||
if (!app.globalData.userInfo?.id) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const { sectionId } = this.data
|
||||
if (!sectionId) {
|
||||
wx.showToast({ title: '章节信息异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({ url: `/pages/gift-pay/detail?sectionId=${encodeURIComponent(sectionId)}` })
|
||||
},
|
||||
|
||||
// 复制链接
|
||||
copyLink() {
|
||||
const userInfo = app.globalData.userInfo
|
||||
@@ -791,15 +672,16 @@ Page({
|
||||
|
||||
// 复制分享文案(朋友圈风格)
|
||||
copyShareText() {
|
||||
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
|
||||
const raw = (this.data.content || '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/[#@]\S+/g, '')
|
||||
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
|
||||
const picked = sentences.slice(0, 5)
|
||||
const shareText = title + '\n\n' + picked.join('\n\n')
|
||||
const { section } = this.data
|
||||
|
||||
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
|
||||
|
||||
62个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。
|
||||
|
||||
推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看!
|
||||
|
||||
#创业派对 #私域运营 #商业案例`
|
||||
|
||||
wx.setClipboardData({
|
||||
data: shareText,
|
||||
success: () => {
|
||||
@@ -808,39 +690,29 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 分享到微信 - 自动带分享人ID;优先用 mid(扫码/海报闭环),无则用 id
|
||||
// 分享到微信 - 自动带分享人ID
|
||||
onShareAppMessage() {
|
||||
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
|
||||
const { section, sectionId, sectionMid } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const giftCode = this._giftCodeToShare || ''
|
||||
this._giftCodeToShare = null
|
||||
|
||||
let shareTitle = section?.title
|
||||
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
|
||||
const title = section?.title
|
||||
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
|
||||
: '📚 Soul创业派对 - 真实商业故事'
|
||||
if (giftCode) shareTitle = `🎁 好友已为你解锁:${section?.title || '精选文章'}`
|
||||
|
||||
let path = `/pages/read/read?${q}`
|
||||
if (ref) path += `&ref=${ref}`
|
||||
if (giftCode) path += `&gift=${giftCode}`
|
||||
|
||||
return { title: shareTitle, path }
|
||||
return { title, path }
|
||||
},
|
||||
|
||||
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
|
||||
onShareTimeline() {
|
||||
const { section, sectionId, sectionMid, chapterTitle } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const articleTitle = (section?.title || chapterTitle || '').trim()
|
||||
const title = articleTitle
|
||||
? `📚 ${articleTitle.length > 24 ? articleTitle.slice(0, 24) + '...' : articleTitle}|Soul创业派对`
|
||||
: '📚 Soul创业派对 - 真实商业故事'
|
||||
return { title, query: ref ? `${q}&ref=${ref}` : q }
|
||||
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户
|
||||
onShareTimelineTap() {
|
||||
wx.showToast({
|
||||
title: '请点击右上角「...」→ 分享到朋友圈',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
})
|
||||
},
|
||||
|
||||
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
|
||||
shareToMoments() {
|
||||
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
|
||||
const raw = (this.data.content || '')
|
||||
@@ -850,7 +722,7 @@ Page({
|
||||
.replace(/[#@]\S+/g, '')
|
||||
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
|
||||
const picked = sentences.slice(0, 5)
|
||||
const copyText = title + '\n\n' + picked.join('\n\n')
|
||||
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#Soul创业派对 #真实商业故事`
|
||||
wx.setClipboardData({
|
||||
data: copyText,
|
||||
success: () => {
|
||||
@@ -867,6 +739,19 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 分享到朋友圈:带文章标题,过长时截断
|
||||
onShareTimeline() {
|
||||
const { section, sectionId, sectionMid, chapterTitle } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const query = ref ? `${q}&ref=${ref}` : q
|
||||
const articleTitle = (section?.title || chapterTitle || '').trim()
|
||||
const title = articleTitle
|
||||
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
|
||||
: 'Soul创业派对 - 真实商业故事'
|
||||
return { title, query }
|
||||
},
|
||||
|
||||
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
||||
showLoginModal() {
|
||||
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
|
||||
@@ -1076,6 +961,39 @@ Page({
|
||||
wx.showLoading({ title: '正在发起支付...', mask: true })
|
||||
|
||||
try {
|
||||
// 0. 尝试余额支付(若余额足够)
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||
if (userId) {
|
||||
try {
|
||||
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
|
||||
const balance = balanceRes?.data?.balance || 0
|
||||
if (balance >= amount) {
|
||||
const productId = type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : '')
|
||||
const consumeRes = await app.request({
|
||||
url: '/api/miniprogram/balance/consume',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
productType: type,
|
||||
productId: type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : 'vip_annual'),
|
||||
amount,
|
||||
referralCode: referralCode || undefined
|
||||
}
|
||||
})
|
||||
if (consumeRes?.success) {
|
||||
wx.hideLoading()
|
||||
this.setData({ isPaying: false })
|
||||
wx.showToast({ title: '购买成功', icon: 'success' })
|
||||
await this.onPaymentSuccess()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Pay] 余额支付失败,改用微信支付:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 先获取openId (支付必需)
|
||||
let openId = app.globalData.openId || wx.getStorageSync('openId')
|
||||
|
||||
@@ -1143,18 +1061,15 @@ Page({
|
||||
console.error('[Pay] API创建订单失败:', apiError)
|
||||
wx.hideLoading()
|
||||
// 支付接口失败时,显示客服联系方式
|
||||
const supportWechat = app.globalData.supportWechat || ''
|
||||
wx.showModal({
|
||||
title: '支付通道维护中',
|
||||
content: supportWechat
|
||||
? `微信支付正在审核中,请添加客服微信(${supportWechat})手动购买,感谢理解!`
|
||||
: '微信支付正在审核中,请联系管理员手动购买,感谢理解!',
|
||||
confirmText: supportWechat ? '复制微信号' : '我知道了',
|
||||
content: '微信支付正在审核中,请添加客服微信(28533368)手动购买,感谢理解!',
|
||||
confirmText: '复制微信号',
|
||||
cancelText: '稍后再说',
|
||||
success: (res) => {
|
||||
if (res.confirm && supportWechat) {
|
||||
if (res.confirm) {
|
||||
wx.setClipboardData({
|
||||
data: supportWechat,
|
||||
data: '28533368',
|
||||
success: () => {
|
||||
wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
}
|
||||
@@ -1194,18 +1109,15 @@ Page({
|
||||
wx.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) {
|
||||
// 支付失败,可能是参数错误或权限问题
|
||||
const supportWechat = app.globalData.supportWechat || ''
|
||||
wx.showModal({
|
||||
title: '支付失败',
|
||||
content: supportWechat
|
||||
? `微信支付暂不可用,请添加客服微信(${supportWechat})手动购买`
|
||||
: '微信支付暂不可用,请稍后重试或联系管理员',
|
||||
confirmText: supportWechat ? '复制微信号' : '我知道了',
|
||||
content: '微信支付暂不可用,请添加客服微信(28533368)手动购买',
|
||||
confirmText: '复制微信号',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm && supportWechat) {
|
||||
if (res.confirm) {
|
||||
wx.setClipboardData({
|
||||
data: supportWechat,
|
||||
data: '28533368',
|
||||
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
@@ -1358,11 +1270,6 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/referral/referral' })
|
||||
},
|
||||
|
||||
showPosterModal() {
|
||||
this.setData({ showPosterModal: true })
|
||||
this.generatePoster()
|
||||
},
|
||||
|
||||
// 生成海报
|
||||
async generatePoster() {
|
||||
wx.showLoading({ title: '生成中...' })
|
||||
@@ -1370,14 +1277,15 @@ Page({
|
||||
|
||||
try {
|
||||
const ctx = wx.createCanvasContext('posterCanvas', this)
|
||||
const { section, contentParagraphs, sectionId } = this.data
|
||||
const { section, contentParagraphs, sectionId, sectionMid } = this.data
|
||||
const userInfo = app.globalData.userInfo
|
||||
const userId = userInfo?.id || ''
|
||||
|
||||
// 获取小程序码(带推荐人参数)
|
||||
// 获取小程序码(带推荐人参数,优先 mid 与新链接一致)
|
||||
let qrcodeImage = null
|
||||
try {
|
||||
const scene = userId ? `id=${sectionId}&ref=${userId.slice(0,10)}` : `id=${sectionId}`
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
const scene = userId ? `${q}&ref=${userId.slice(0,10)}` : q
|
||||
const qrRes = await app.request('/api/miniprogram/qrcode', {
|
||||
method: 'POST',
|
||||
data: { scene, page: 'pages/read/read', width: 280 }
|
||||
@@ -1527,160 +1435,7 @@ Page({
|
||||
closePosterModal() {
|
||||
this.setData({ showPosterModal: false })
|
||||
},
|
||||
|
||||
closeShareTip() {
|
||||
this.setData({ showShareTip: false })
|
||||
},
|
||||
|
||||
// 代付分享:微信支付或余额帮好友解锁当前章节
|
||||
async handleGiftPay() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } })
|
||||
return
|
||||
}
|
||||
const sectionId = this.data.sectionId
|
||||
const userId = app.globalData.userInfo.id
|
||||
const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1)
|
||||
|
||||
wx.showModal({
|
||||
title: '代付分享',
|
||||
content: `为好友代付本章 ¥${price}\n支付后将生成代付链接,好友点击即可免费阅读`,
|
||||
confirmText: '确认代付',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
wx.showActionSheet({
|
||||
itemList: ['微信支付', '用余额支付'],
|
||||
success: async (actionRes) => {
|
||||
if (actionRes.tapIndex === 0) {
|
||||
this._giftPayViaWechat(sectionId, userId, price)
|
||||
} else if (actionRes.tapIndex === 1) {
|
||||
this._giftPayViaBalance(sectionId, userId, price)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async _giftPayViaWechat(sectionId, userId, price) {
|
||||
let openId = app.globalData.openId || wx.getStorageSync('openId')
|
||||
if (!openId) { openId = await app.getOpenId() }
|
||||
if (!openId) { wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none' }); return }
|
||||
wx.showLoading({ title: '创建订单...' })
|
||||
try {
|
||||
const payRes = await app.request({
|
||||
url: '/api/miniprogram/pay',
|
||||
method: 'POST',
|
||||
data: {
|
||||
openId: openId,
|
||||
productType: 'gift',
|
||||
productId: sectionId,
|
||||
amount: price,
|
||||
description: `代付解锁:${this.data.section?.title || sectionId}`,
|
||||
userId: userId,
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
const params = (payRes && payRes.data && payRes.data.payParams) ? payRes.data.payParams : (payRes && payRes.payParams ? payRes.payParams : null)
|
||||
if (params) {
|
||||
wx.requestPayment({
|
||||
...params,
|
||||
success: async () => {
|
||||
wx.showLoading({ title: '生成分享链接...' })
|
||||
try {
|
||||
const giftRes = await app.request({
|
||||
url: '/api/miniprogram/balance/gift',
|
||||
method: 'POST',
|
||||
data: { giverId: userId, sectionId, paidViaWechat: true }
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (giftRes && giftRes.data && giftRes.data.giftCode) {
|
||||
this._giftCodeToShare = giftRes.data.giftCode
|
||||
wx.showModal({
|
||||
title: '代付成功',
|
||||
content: `已为好友代付 ¥${price},分享后好友可免费阅读`,
|
||||
confirmText: '分享给好友',
|
||||
cancelText: '稍后分享',
|
||||
success: (r) => { if (r.confirm) wx.shareAppMessage() }
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: '支付成功,请手动分享', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '支付成功,生成链接失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => { wx.showToast({ title: '支付已取消', icon: 'none' }) }
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: '创建支付失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
console.error('[GiftPay] WeChat pay error:', e)
|
||||
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async _giftPayViaBalance(sectionId, userId, price) {
|
||||
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
|
||||
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
|
||||
|
||||
if (balance < price) {
|
||||
wx.showModal({
|
||||
title: '余额不足',
|
||||
content: `当前余额 ¥${balance.toFixed(2)},需要 ¥${price}\n请先充值`,
|
||||
confirmText: '去充值',
|
||||
cancelText: '取消',
|
||||
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/wallet/wallet' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '处理中...' })
|
||||
try {
|
||||
const giftRes = await app.request({
|
||||
url: '/api/miniprogram/balance/gift',
|
||||
method: 'POST',
|
||||
data: { giverId: userId, sectionId }
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (giftRes && giftRes.data && giftRes.data.giftCode) {
|
||||
this._giftCodeToShare = giftRes.data.giftCode
|
||||
wx.showModal({
|
||||
title: '代付成功',
|
||||
content: `已从余额扣除 ¥${price},分享后好友可免费阅读`,
|
||||
confirmText: '分享给好友',
|
||||
cancelText: '稍后分享',
|
||||
success: (r) => { if (r.confirm) wx.shareAppMessage() }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '代付失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 领取礼物码解锁
|
||||
async _redeemGiftCode(giftCode) {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/balance/gift/redeem',
|
||||
method: 'POST',
|
||||
data: { giftCode, receiverId: app.globalData.userInfo.id }
|
||||
})
|
||||
if (res && res.data) {
|
||||
wx.showToast({ title: '好友已为你解锁!', icon: 'success' })
|
||||
this.onLoad({ id: this.data.sectionId })
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Gift] 领取失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 保存海报到相册
|
||||
savePoster() {
|
||||
wx.canvasToTempFilePath({
|
||||
@@ -1764,8 +1519,7 @@ Page({
|
||||
readingTracker.init(this.data.sectionId)
|
||||
}
|
||||
|
||||
// 加载导航
|
||||
this.loadNavigation(this.data.sectionId)
|
||||
this._applyPrevNext(chapterRes)
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '加载成功', icon: 'success' })
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-arrow">←</text>
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-arrow"></icon>
|
||||
</view>
|
||||
<view class="nav-info">
|
||||
<text class="nav-chapter" wx:if="{{section.title || chapterTitle}}">{{section.title || chapterTitle}}</text>
|
||||
@@ -24,31 +24,40 @@
|
||||
|
||||
<!-- 阅读内容 -->
|
||||
<view class="read-content">
|
||||
<!-- 章节标题 -->
|
||||
<view class="chapter-header" wx:if="{{section}}">
|
||||
<!-- 骨架屏:加载中时展示,模拟章节标题+正文布局 -->
|
||||
<view class="skeleton-wrap" wx:if="{{accessState === 'unknown' && loading}}">
|
||||
<view class="skeleton-header">
|
||||
<view class="skeleton-meta"></view>
|
||||
<view class="skeleton-title"></view>
|
||||
</view>
|
||||
<view class="skeleton-lines">
|
||||
<view class="skeleton skeleton-1"></view>
|
||||
<view class="skeleton skeleton-2"></view>
|
||||
<view class="skeleton skeleton-3"></view>
|
||||
<view class="skeleton skeleton-4"></view>
|
||||
<view class="skeleton skeleton-5"></view>
|
||||
<view class="skeleton skeleton-6"></view>
|
||||
<view class="skeleton skeleton-7"></view>
|
||||
<view class="skeleton skeleton-8"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 章节标题(加载完成后) -->
|
||||
<view class="chapter-header" wx:elif="{{!loading}}">
|
||||
<view class="chapter-meta">
|
||||
<text class="chapter-id">{{section.id}}</text>
|
||||
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
|
||||
</view>
|
||||
<text class="chapter-title">{{section.title}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-state" wx:if="{{accessState === 'unknown' && loading}}">
|
||||
<view class="skeleton skeleton-1"></view>
|
||||
<view class="skeleton skeleton-2"></view>
|
||||
<view class="skeleton skeleton-3"></view>
|
||||
<view class="skeleton skeleton-4"></view>
|
||||
<view class="skeleton skeleton-5"></view>
|
||||
<text class="chapter-title" user-select>{{section.title}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片) -->
|
||||
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
|
||||
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
|
||||
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
|
||||
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
|
||||
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
|
||||
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
|
||||
<text wx:if="{{seg.type === 'text'}}" user-select>{{seg.text}}</text>
|
||||
<text wx:elif="{{seg.type === 'mention'}}" class="mention" user-select bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
|
||||
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" user-select bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
|
||||
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
|
||||
</block>
|
||||
</view>
|
||||
@@ -74,31 +83,31 @@
|
||||
<text class="btn-label">下一篇</text>
|
||||
<view class="btn-row">
|
||||
<text class="btn-title">{{nextSection.title}}</text>
|
||||
<text class="btn-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="#00CED1" customClass="btn-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-btn nav-end" wx:else>
|
||||
<text class="btn-end-text">已是最后一篇 🎉</text>
|
||||
<text class="btn-end-text">已是最后一篇</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享操作区 -->
|
||||
<view class="action-section">
|
||||
<view class="action-row-inline">
|
||||
<button class="action-btn-inline btn-share-inline" open-type="share">
|
||||
<text class="action-icon-small">📣</text>
|
||||
<text class="action-text-small">分享给好友</text>
|
||||
</button>
|
||||
<view class="action-btn-inline btn-gift-inline" bindtap="handleGiftPay">
|
||||
<text class="action-icon-small">🎁</text>
|
||||
<text class="action-text-small">代付分享</text>
|
||||
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
|
||||
<icon name="megaphone" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">分享到朋友圈</text>
|
||||
</view>
|
||||
<view class="action-btn-inline btn-poster-inline" bindtap="showPosterModal">
|
||||
<text class="action-icon-small">🖼️</text>
|
||||
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
|
||||
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">生成海报</text>
|
||||
</view>
|
||||
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">代付分享</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="share-tip-inline">
|
||||
<view class="share-tip-inline" wx:if="{{!auditMode}}">
|
||||
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -109,7 +118,7 @@
|
||||
<!-- 预览内容 + 付费墙 - 未登录 -->
|
||||
<view class="article preview" wx:if="{{accessState === 'locked_not_login'}}">
|
||||
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
|
||||
{{item}}
|
||||
<text user-select>{{item}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 渐变遮罩 -->
|
||||
@@ -117,7 +126,7 @@
|
||||
|
||||
<!-- 付费墙 - 未登录 -->
|
||||
<view class="paywall">
|
||||
<view class="paywall-icon">🔒</view>
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="paywall-title">登录后继续阅读</text>
|
||||
<text class="paywall-desc">已阅读50%,登录后查看完整内容</text>
|
||||
|
||||
@@ -147,11 +156,11 @@
|
||||
<text class="btn-label">下一篇</text>
|
||||
<view class="btn-row">
|
||||
<text class="btn-title">{{nextSection.title}}</text>
|
||||
<text class="btn-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="#00CED1" customClass="btn-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-btn nav-end" wx:else>
|
||||
<text class="btn-end-text">已是最后一篇 🎉</text>
|
||||
<text class="btn-end-text">已是最后一篇</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -160,7 +169,7 @@
|
||||
<!-- 预览内容 + 付费墙 - 已登录未购买 -->
|
||||
<view class="article preview" wx:if="{{accessState === 'locked_not_purchased'}}">
|
||||
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
|
||||
{{item}}
|
||||
<text user-select>{{item}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 渐变遮罩 -->
|
||||
@@ -168,12 +177,12 @@
|
||||
|
||||
<!-- 付费墙 - 已登录未购买 -->
|
||||
<view class="paywall">
|
||||
<view class="paywall-icon">🔒</view>
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
|
||||
|
||||
<!-- 购买选项 -->
|
||||
<view class="purchase-options">
|
||||
<!-- 购买选项(审核模式隐藏) -->
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<!-- 购买本章 - 直接调起支付 -->
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
<text class="btn-label">购买本章</text>
|
||||
@@ -183,7 +192,7 @@
|
||||
<!-- 解锁全书 - 只有购买超过3章才显示 -->
|
||||
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
|
||||
<view class="btn-left">
|
||||
<text class="btn-sparkle">✨</text>
|
||||
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
|
||||
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
||||
</view>
|
||||
<view class="btn-right">
|
||||
@@ -192,8 +201,14 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
|
||||
<text class="paywall-tip">分享给好友一起学习,还能赚取佣金</text>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
|
||||
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
|
||||
<text class="gift-share-text">代付分享</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 章节导航 -->
|
||||
@@ -217,11 +232,11 @@
|
||||
<text class="btn-label">下一篇</text>
|
||||
<view class="btn-row">
|
||||
<text class="btn-title">{{nextSection.title}}</text>
|
||||
<text class="btn-arrow">→</text>
|
||||
<icon name="chevron-right" size="28" color="#00CED1" customClass="btn-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-btn nav-end" wx:else>
|
||||
<text class="btn-end-text">已是最后一篇 🎉</text>
|
||||
<text class="btn-end-text">已是最后一篇</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -230,7 +245,7 @@
|
||||
<!-- 错误状态 - 网络异常 -->
|
||||
<view class="article preview" wx:if="{{accessState === 'error'}}">
|
||||
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
|
||||
{{item}}
|
||||
<text user-select>{{item}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 渐变遮罩 -->
|
||||
@@ -238,7 +253,7 @@
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<view class="paywall">
|
||||
<view class="paywall-icon">⚠️</view>
|
||||
<view class="paywall-icon"><icon name="warning" size="80" color="#ff9500"></icon></view>
|
||||
<text class="paywall-title">网络异常</text>
|
||||
<text class="paywall-desc">无法确认权限,请检查网络后重试</text>
|
||||
|
||||
@@ -249,20 +264,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享提示浮层(阅读20%后下拉触发) -->
|
||||
<view class="share-float-tip {{showShareTip ? 'show' : ''}}" wx:if="{{showShareTip}}">
|
||||
<text class="share-float-icon">💰</text>
|
||||
<text class="share-float-text">分享给好友,好友购买你可获得 90% 收益</text>
|
||||
<button class="share-float-btn" open-type="share">立即分享</button>
|
||||
<view class="share-float-close" bindtap="closeShareTip">✕</view>
|
||||
</view>
|
||||
|
||||
<!-- 海报生成弹窗 -->
|
||||
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
|
||||
<view class="modal-content poster-modal" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">生成海报</text>
|
||||
<view class="modal-close" bindtap="closePosterModal">✕</view>
|
||||
<view class="modal-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
|
||||
<!-- 海报预览 -->
|
||||
@@ -272,7 +279,7 @@
|
||||
|
||||
<view class="poster-actions">
|
||||
<view class="poster-btn btn-save" bindtap="savePoster">
|
||||
<text class="btn-icon">💾</text>
|
||||
<icon name="save" size="36" color="#8e8e93" customClass="btn-icon"></icon>
|
||||
<text>保存到相册</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -284,8 +291,8 @@
|
||||
<!-- 登录弹窗 - 须勾选同意协议,《用户协议》《隐私政策》可点击查看 -->
|
||||
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
|
||||
<view class="modal-content login-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeLoginModal">✕</view>
|
||||
<view class="login-icon">🔐</view>
|
||||
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="login-title">登录 Soul创业派对</text>
|
||||
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
|
||||
|
||||
@@ -295,7 +302,7 @@
|
||||
</button>
|
||||
|
||||
<view class="login-agree-row" catchtap="toggleAgree">
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}">{{agreeProtocol ? '✓' : ''}}</view>
|
||||
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
|
||||
<text class="agree-text">我已阅读并同意</text>
|
||||
<text class="agree-link" catchtap="openUserProtocol">《用户协议》</text>
|
||||
<text class="agree-text">和</text>
|
||||
@@ -314,6 +321,6 @@
|
||||
|
||||
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
|
||||
<view class="fab-share" bindtap="shareToMoments">
|
||||
<text class="fab-moments-icon">🌐</text>
|
||||
<icon name="globe" size="40" color="#ffffff" customClass="fab-moments-icon"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -144,8 +144,35 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ===== 加载状态 ===== */
|
||||
.loading-state {
|
||||
/* ===== 骨架屏 ===== */
|
||||
.skeleton-wrap {
|
||||
padding-top: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.skeleton-meta {
|
||||
width: 120rpx;
|
||||
height: 48rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 85%;
|
||||
height: 52rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
@@ -164,6 +191,9 @@
|
||||
.skeleton-3 { width: 65%; }
|
||||
.skeleton-4 { width: 85%; }
|
||||
.skeleton-5 { width: 70%; }
|
||||
.skeleton-6 { width: 80%; }
|
||||
.skeleton-7 { width: 60%; }
|
||||
.skeleton-8 { width: 88%; }
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
@@ -336,6 +366,12 @@
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
.paywall-audit-tip {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
/* ===== 章节导航 ===== */
|
||||
.chapter-nav {
|
||||
@@ -348,23 +384,20 @@
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 48rpx;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
max-width: 48%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-btn-placeholder {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 48%;
|
||||
}
|
||||
|
||||
.nav-prev {
|
||||
@@ -405,12 +438,16 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
@@ -432,22 +469,24 @@
|
||||
|
||||
.action-row-inline {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.action-btn-inline {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 24rpx 16rpx;
|
||||
padding: 24rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn-inline::after {
|
||||
@@ -460,25 +499,34 @@
|
||||
}
|
||||
|
||||
.btn-poster-inline {
|
||||
background: linear-gradient(135deg, #2d2d30 0%, #3d3d40 100%);
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 2rpx solid rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-moments-inline {
|
||||
background: linear-gradient(135deg, #1a4a2e, #0d3320);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
.btn-moments-inline:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-icon-small {
|
||||
font-size: 40rpx;
|
||||
font-size: 28rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-text-small {
|
||||
font-size: 22rpx;
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.share-tip-inline {
|
||||
margin-top: 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.share-tip-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ===== 推广提示区 ===== */
|
||||
@@ -592,6 +640,97 @@
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ===== 代付分享 ===== */
|
||||
.btn-gift-inline {
|
||||
/* 与 btn-share-inline 同风格 */
|
||||
}
|
||||
.gift-share-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx;
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
border-radius: 24rpx;
|
||||
border: 1rpx solid rgba(255, 215, 0, 0.2);
|
||||
}
|
||||
.gift-share-icon { font-size: 32rpx; }
|
||||
.gift-share-text { font-size: 28rpx; color: #FFD700; }
|
||||
|
||||
/* 代付分享弹窗 */
|
||||
.gift-modal { padding: 32rpx; }
|
||||
.gift-desc {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.gift-form { margin-bottom: 32rpx; }
|
||||
.gift-label {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.form-input-wrap {
|
||||
padding: 16rpx 24rpx;
|
||||
background: #1F2937;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
.form-input-inner {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
.gift-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.gift-btn {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
.gift-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.gift-confirm {
|
||||
background: #00CED1;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-modal-desc {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.share-modal-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
.share-modal-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 24rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.share-modal-btn .btn-icon { font-size: 48rpx; }
|
||||
.share-modal-btn text:last-child { font-size: 26rpx; color: rgba(255, 255, 255, 0.8); }
|
||||
|
||||
/* ===== 分享弹窗 ===== */
|
||||
.share-link-box {
|
||||
padding: 32rpx;
|
||||
@@ -1016,80 +1155,7 @@
|
||||
}
|
||||
|
||||
.fab-moments-icon {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
/* ===== 分享提示文字(底部导航上方) ===== */
|
||||
.share-tip-inline {
|
||||
text-align: center;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.share-tip-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ===== 分享浮层提示(阅读20%触发) ===== */
|
||||
.share-float-tip {
|
||||
position: fixed;
|
||||
top: 180rpx;
|
||||
left: 40rpx;
|
||||
right: 40rpx;
|
||||
background: linear-gradient(135deg, #1a3a4a 0%, #0d2533 100%);
|
||||
border: 1rpx solid rgba(0, 206, 209, 0.3);
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transform: translateY(-40rpx);
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
.share-float-tip.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.share-float-icon {
|
||||
font-size: 40rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.share-float-text {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
flex: 1;
|
||||
}
|
||||
.share-float-btn {
|
||||
background: linear-gradient(135deg, #00CED1, #20B2AA) !important;
|
||||
color: #fff !important;
|
||||
font-size: 24rpx;
|
||||
padding: 10rpx 28rpx;
|
||||
border-radius: 32rpx;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.share-float-btn::after {
|
||||
border: none;
|
||||
}
|
||||
.share-float-close {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
padding: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== 代付分享按钮 ===== */
|
||||
.btn-gift-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 165, 0, 0.1);
|
||||
border: 1rpx solid rgba(255, 165, 0, 0.3);
|
||||
font-size: 44rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,9 @@ Page({
|
||||
posterReferralLink: '',
|
||||
posterNickname: '',
|
||||
posterNicknameInitial: '',
|
||||
posterCaseCount: 62
|
||||
},
|
||||
posterCaseCount: 62,
|
||||
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
@@ -93,17 +94,28 @@ Page({
|
||||
// 生成邀请码
|
||||
const referralCode = userInfo.referralCode || 'SOUL' + (userInfo.id || Date.now().toString(36)).toUpperCase().slice(-6)
|
||||
|
||||
console.log('[Referral] 开始加载分销数据,userId:', userInfo.id)
|
||||
|
||||
// 从API获取真实数据
|
||||
let realData = null
|
||||
try {
|
||||
// app.request 第一个参数是 URL 字符串(会自动拼接 baseUrl)
|
||||
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
|
||||
console.log('[Referral] API返回:', JSON.stringify(res).substring(0, 200))
|
||||
|
||||
if (res && res.success && res.data) {
|
||||
realData = res.data
|
||||
console.log('[Referral] ✅ 获取推广数据成功')
|
||||
console.log('[Referral] - bindingCount:', realData.bindingCount)
|
||||
console.log('[Referral] - paidCount:', realData.paidCount)
|
||||
console.log('[Referral] - earnings:', realData.earnings)
|
||||
console.log('[Referral] - expiringCount:', realData.stats?.expiringCount)
|
||||
} else {
|
||||
console.log('[Referral] ❌ API返回格式错误:', res?.error || 'unknown')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Referral] 加载分销数据失败:', e && e.message ? e.message : e)
|
||||
console.log('[Referral] ❌ API调用失败:', e.message || e)
|
||||
console.log('[Referral] 错误详情:', e)
|
||||
}
|
||||
|
||||
// 使用真实数据或默认值
|
||||
@@ -111,9 +123,15 @@ Page({
|
||||
let convertedBindings = realData?.convertedUsers || []
|
||||
let expiredBindings = realData?.expiredUsers || []
|
||||
|
||||
console.log('[Referral] activeBindings:', activeBindings.length)
|
||||
console.log('[Referral] convertedBindings:', convertedBindings.length)
|
||||
console.log('[Referral] expiredBindings:', expiredBindings.length)
|
||||
|
||||
// 计算即将过期的数量(7天内)
|
||||
const expiringCount = realData?.stats?.expiringCount || activeBindings.filter(b => b.daysRemaining <= 7 && b.daysRemaining > 0).length
|
||||
|
||||
console.log('[Referral] expiringCount:', expiringCount)
|
||||
|
||||
// 计算各类统计
|
||||
const bindingCount = realData?.bindingCount || activeBindings.length
|
||||
const paidCount = realData?.paidCount || convertedBindings.length
|
||||
@@ -135,6 +153,7 @@ Page({
|
||||
purchaseCount: user.purchaseCount || 0,
|
||||
conversionDate: user.conversionDate ? this.formatDate(user.conversionDate) : '--'
|
||||
}
|
||||
console.log('[Referral] 格式化用户:', formatted.nickname, formatted.status, formatted.daysRemaining + '天')
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -150,6 +169,15 @@ Page({
|
||||
const availableEarningsNum = Math.max(0, totalCommissionNum - withdrawnNum - pendingWithdrawNum)
|
||||
const minWithdrawAmount = realData?.minWithdrawAmount || 10
|
||||
|
||||
console.log('=== [Referral] 收益计算(完整版)===')
|
||||
console.log('累计佣金 (totalCommission):', totalCommissionNum)
|
||||
console.log('已提现金额 (withdrawnEarnings):', withdrawnNum)
|
||||
console.log('待审核金额 (pendingWithdrawAmount):', pendingWithdrawNum)
|
||||
console.log('可提现金额 = 累计 - 已提现 - 待审核 =', totalCommissionNum, '-', withdrawnNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
|
||||
console.log('最低提现金额 (minWithdrawAmount):', minWithdrawAmount)
|
||||
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
|
||||
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
|
||||
|
||||
const hasWechatId = !!(userInfo?.wechat || userInfo?.wechatId || wx.getStorageSync('user_wechat'))
|
||||
this.setData({
|
||||
isLoggedIn: true,
|
||||
@@ -205,6 +233,21 @@ Page({
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
console.log('[Referral] ✅ 数据设置完成')
|
||||
console.log('[Referral] - 绑定中:', this.data.bindingCount)
|
||||
console.log('[Referral] - 即将过期:', this.data.expiringCount)
|
||||
console.log('[Referral] - 收益:', this.data.earnings)
|
||||
|
||||
console.log('=== [Referral] 按钮状态验证 ===')
|
||||
console.log('累计佣金 (totalCommission):', this.data.totalCommission)
|
||||
console.log('待审核金额 (pendingWithdrawAmount):', this.data.pendingWithdrawAmount)
|
||||
console.log('可提现金额 (availableEarnings 显示):', this.data.availableEarnings)
|
||||
console.log('可提现金额 (availableEarningsNum 判断):', this.data.availableEarningsNum, typeof this.data.availableEarningsNum)
|
||||
console.log('最低提现金额 (minWithdrawAmount):', this.data.minWithdrawAmount, typeof this.data.minWithdrawAmount)
|
||||
console.log('按钮启用条件:', this.data.availableEarningsNum, '>=', this.data.minWithdrawAmount, '=', this.data.availableEarningsNum >= this.data.minWithdrawAmount)
|
||||
console.log('✅ 最终结果: 按钮应该', this.data.availableEarningsNum >= this.data.minWithdrawAmount ? '🟢 启用' : '⚫ 禁用')
|
||||
|
||||
// 隐藏加载提示
|
||||
wx.hideLoading()
|
||||
} else {
|
||||
@@ -215,8 +258,8 @@ Page({
|
||||
|
||||
// 切换Tab
|
||||
switchTab(e) {
|
||||
trackClick('referral', 'tab_click', e.currentTarget.dataset.tab || 'tab')
|
||||
const tab = e.currentTarget.dataset.tab
|
||||
trackClick('referral', 'tab_click', tab || '绑定列表')
|
||||
let currentBindings = []
|
||||
|
||||
if (tab === 'active') {
|
||||
@@ -247,7 +290,7 @@ Page({
|
||||
|
||||
// 分享到朋友圈 - 1:1 迁移 Next.js 的 handleShareToWechat
|
||||
shareToWechat() {
|
||||
trackClick('referral', 'btn_click', '分享朋友圈文案')
|
||||
trackClick('referral', 'btn_click', '分享朋友圈')
|
||||
const { referralCode } = this.data
|
||||
const referralLink = `https://soul.quwanzhi.com/?ref=${referralCode}`
|
||||
|
||||
@@ -487,7 +530,6 @@ Page({
|
||||
|
||||
// 保存海报
|
||||
savePoster() {
|
||||
trackClick('referral', 'btn_click', '保存海报')
|
||||
const { posterQrSrc } = this.data
|
||||
if (!posterQrSrc) {
|
||||
wx.showToast({ title: '二维码未生成', icon: 'none' })
|
||||
@@ -588,7 +630,7 @@ Page({
|
||||
|
||||
// 提现 - 直接到微信零钱
|
||||
async handleWithdraw() {
|
||||
trackClick('referral', 'btn_click', '提现')
|
||||
trackClick('referral', 'btn_click', '申请提现')
|
||||
const availableEarnings = this.data.availableEarningsNum || 0
|
||||
const minWithdrawAmount = this.data.minWithdrawAmount || 10
|
||||
const hasWechatId = this.data.hasWechatId
|
||||
@@ -635,7 +677,7 @@ Page({
|
||||
|
||||
// 跳转提现记录页
|
||||
goToWithdrawRecords() {
|
||||
trackClick('referral', 'btn_click', '提现记录')
|
||||
trackClick('referral', 'nav_click', '提现记录')
|
||||
wx.navigateTo({ url: '/pages/withdraw-records/withdraw-records' })
|
||||
},
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<text class="title-text">绑定用户</text>
|
||||
<text class="binding-count">({{totalBindings}})</text>
|
||||
</view>
|
||||
<text class="toggle-icon">{{showBindingList ? '▲' : '▼'}}</text>
|
||||
<icon name="{{showBindingList ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="toggle-icon"></icon>
|
||||
</view>
|
||||
|
||||
<block wx:if="{{showBindingList}}">
|
||||
@@ -121,7 +121,7 @@
|
||||
<view class="binding-list">
|
||||
<block wx:if="{{currentBindings.length === 0}}">
|
||||
<view class="empty-state">
|
||||
<text class="empty-icon">👤</text>
|
||||
<icon name="user" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
|
||||
<text class="empty-text">暂无用户</text>
|
||||
</view>
|
||||
</block>
|
||||
@@ -132,8 +132,8 @@
|
||||
wx:key="id"
|
||||
>
|
||||
<view class="user-avatar {{item.status === 'converted' ? 'avatar-converted' : item.status === 'expired' ? 'avatar-expired' : ''}}">
|
||||
<text wx:if="{{item.status === 'converted'}}">✓</text>
|
||||
<text wx:elif="{{item.status === 'expired'}}">⏰</text>
|
||||
<icon wx:if="{{item.status === 'converted'}}" name="check" size="28" color="#34C759"></icon>
|
||||
<icon wx:elif="{{item.status === 'expired'}}" name="clock" size="28" color="#ff9500"></icon>
|
||||
<text wx:else>{{item.nickname[0] || '用'}}</text>
|
||||
</view>
|
||||
<view class="user-info">
|
||||
@@ -246,7 +246,7 @@
|
||||
<!-- 海报生成弹窗 - 优化小程序显示 -->
|
||||
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
|
||||
<view class="poster-dialog" catchtap="stopPropagation">
|
||||
<view class="poster-close" bindtap="closePosterModal">✕</view>
|
||||
<view class="poster-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
|
||||
<!-- 上半部分:海报内容(不使用画布,纯布局 + 二维码图片) -->
|
||||
<view class="poster-card">
|
||||
|
||||
@@ -8,13 +8,15 @@ const { trackClick } = require('../../utils/trackClick')
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
navBarHeight: 88,
|
||||
capsuleRightPadding: 96,
|
||||
keyword: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
searched: false,
|
||||
total: 0,
|
||||
// 热门搜索关键词(运行时根据热门章节/目录动态生成)
|
||||
hotKeywords: [],
|
||||
// 热门搜索关键词
|
||||
hotKeywords: ['私域', '电商', '流量', '赚钱', '创业', 'Soul', '抖音', '变现'],
|
||||
// 热门章节推荐
|
||||
hotChapters: [
|
||||
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', part: '真实的人' },
|
||||
@@ -28,7 +30,9 @@ Page({
|
||||
onLoad() {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
navBarHeight: app.globalData.navBarHeight || 88,
|
||||
capsuleRightPadding: app.globalData.capsuleRightPadding || 96
|
||||
})
|
||||
// 加载热门章节
|
||||
this.loadHotChapters()
|
||||
@@ -37,7 +41,7 @@ Page({
|
||||
// 加载热门章节(从服务器获取点击量高的章节)
|
||||
async loadHotChapters() {
|
||||
try {
|
||||
const res = await app.request('/api/miniprogram/book/hot')
|
||||
const res = await app.request('/api/miniprogram/book/hot?limit=50')
|
||||
const list = (res && res.data) || (res && res.chapters) || []
|
||||
if (list.length > 0) {
|
||||
const hotChapters = list.map((c, i) => ({
|
||||
@@ -47,36 +51,13 @@ Page({
|
||||
part: c.part_title || c.partTitle || c.part || '',
|
||||
tag: ['免费', '热门', '推荐', '最新'][i % 4] || '热门'
|
||||
}))
|
||||
this.setData({
|
||||
hotChapters,
|
||||
hotKeywords: this.buildHotKeywords(hotChapters)
|
||||
})
|
||||
} else {
|
||||
this.setData({ hotKeywords: this.buildHotKeywords(app.globalData.bookData || []) })
|
||||
this.setData({ hotChapters })
|
||||
}
|
||||
} catch (e) {
|
||||
this.setData({ hotKeywords: this.buildHotKeywords(app.globalData.bookData || []) })
|
||||
console.log('加载热门章节失败,使用默认数据')
|
||||
}
|
||||
},
|
||||
|
||||
buildHotKeywords(sourceList) {
|
||||
const words = []
|
||||
const pushWord = (word) => {
|
||||
const w = (word || '').trim()
|
||||
if (!w || w.length < 2 || words.includes(w)) return
|
||||
words.push(w)
|
||||
}
|
||||
|
||||
;(sourceList || []).forEach((item) => {
|
||||
const title = String(item.title || '').replace(/[||::,.,。!!??]/g, ' ')
|
||||
const part = String(item.part || '').replace(/[||::,.,。!!??]/g, ' ')
|
||||
title.split(/\s+/).forEach(pushWord)
|
||||
part.split(/\s+/).forEach(pushWord)
|
||||
})
|
||||
|
||||
return words.slice(0, 8)
|
||||
},
|
||||
|
||||
// 输入关键词
|
||||
onInput(e) {
|
||||
this.setData({ keyword: e.detail.value })
|
||||
@@ -95,6 +76,7 @@ Page({
|
||||
// 点击热门关键词
|
||||
onHotKeyword(e) {
|
||||
const keyword = e.currentTarget.dataset.keyword
|
||||
trackClick('search', 'tab_click', keyword || '关键词')
|
||||
this.setData({ keyword })
|
||||
this.doSearch()
|
||||
},
|
||||
@@ -102,12 +84,12 @@ Page({
|
||||
// 执行搜索
|
||||
async doSearch() {
|
||||
const { keyword } = this.data
|
||||
if (keyword && keyword.trim().length >= 1) trackClick('search', 'btn_click', '搜索_' + keyword.trim())
|
||||
if (!keyword || keyword.trim().length < 1) {
|
||||
wx.showToast({ title: '请输入搜索关键词', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
trackClick('search', 'btn_click', keyword.trim())
|
||||
this.setData({ loading: true, searched: true })
|
||||
|
||||
try {
|
||||
@@ -122,6 +104,7 @@ Page({
|
||||
this.setData({ results: [], total: 0 })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('搜索失败:', e)
|
||||
wx.showToast({ title: '搜索失败', icon: 'none' })
|
||||
this.setData({ results: [], total: 0 })
|
||||
} finally {
|
||||
@@ -132,8 +115,8 @@ Page({
|
||||
// 跳转阅读(优先传 mid,与分享逻辑一致)
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
trackClick('search', 'card_click', id)
|
||||
trackClick('search', 'card_click', id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!--pages/search/search.wxml-->
|
||||
<!--章节搜索页-->
|
||||
<view class="page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<!-- 自定义导航栏(避开胶囊) -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-content">
|
||||
<view class="nav-content" style="height: {{navBarHeight - statusBarHeight}}px; padding-right: {{capsuleRightPadding}}px;">
|
||||
<view class="back-btn" bindtap="goBack">
|
||||
<text class="back-icon">←</text>
|
||||
<icon name="chevron-left" size="40" color="#8e8e93" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<view class="search-input-wrap">
|
||||
<view class="search-icon-small">🔍</view>
|
||||
<view class="search-icon-small"><icon name="search" size="36" color="#8e8e93"></icon></view>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索章节标题或内容..."
|
||||
@@ -18,14 +18,14 @@
|
||||
confirm-type="search"
|
||||
focus="{{true}}"
|
||||
/>
|
||||
<view class="clear-btn" wx:if="{{keyword}}" bindtap="clearSearch">×</view>
|
||||
<view class="clear-btn" wx:if="{{keyword}}" bindtap="clearSearch"><icon name="x" size="32" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
<view class="search-btn" bindtap="doSearch">搜索</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<view class="main-content" style="padding-top: {{statusBarHeight + 56}}px;">
|
||||
<view class="main-content" style="padding-top: {{navBarHeight}}px;">
|
||||
|
||||
<!-- 热门搜索(未搜索时显示) -->
|
||||
<view class="hot-section" wx:if="{{!searched}}">
|
||||
@@ -65,10 +65,15 @@
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<view class="results-section" wx:if="{{searched}}">
|
||||
<!-- 加载中 -->
|
||||
<view class="loading-wrap" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">搜索中...</text>
|
||||
<!-- 搜索结果骨架屏 -->
|
||||
<view class="skeleton-results" wx:if="{{loading}}">
|
||||
<view class="skeleton-result-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this">
|
||||
<view class="skeleton-result-rank"></view>
|
||||
<view class="skeleton-result-content">
|
||||
<view class="skeleton-result-title"></view>
|
||||
<view class="skeleton-result-meta"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
@@ -99,14 +104,14 @@
|
||||
<view class="result-content" wx:if="{{item.matchedContent}}">
|
||||
<text class="content-preview">{{item.matchedContent}}</text>
|
||||
</view>
|
||||
<view class="result-arrow">→</view>
|
||||
<icon name="chevron-right" size="28" color="#8e8e93" customClass="result-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 无结果 -->
|
||||
<view class="empty-wrap" wx:elif="{{!loading}}">
|
||||
<text class="empty-icon">🔍</text>
|
||||
<icon name="search" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
|
||||
<text class="empty-text">未找到相关章节</text>
|
||||
<text class="empty-hint">换个关键词试试</text>
|
||||
</view>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 24rpx;
|
||||
height: 88rpx;
|
||||
/* height、padding-right 由 wxml 内联传入,以避开胶囊 */
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -284,30 +284,57 @@
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-wrap {
|
||||
/* 搜索结果骨架屏 */
|
||||
.skeleton-results {
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx 0;
|
||||
border-bottom: 1rpx solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.skeleton-result-rank {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-result-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 4rpx solid rgba(0, 206, 209, 0.3);
|
||||
border-top-color: #00CED1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
.skeleton-result-title {
|
||||
width: 85%;
|
||||
height: 36rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
.skeleton-result-meta {
|
||||
width: 50%;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255,255,255,0.5);
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.6)" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">设置</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- 账号绑定 -->
|
||||
<view class="bind-card" wx:if="{{isLoggedIn}}">
|
||||
<view class="card-header">
|
||||
<text class="card-icon">🛡️</text>
|
||||
<icon name="shield" size="48" color="#00CED1" customClass="card-icon"></icon>
|
||||
<view class="card-title-wrap">
|
||||
<text class="card-title">账号绑定</text>
|
||||
<text class="card-desc">绑定后可用于提现和找伙伴功能</text>
|
||||
@@ -24,14 +24,14 @@
|
||||
<!-- 手机号 - 使用微信一键获取 -->
|
||||
<view class="bind-item">
|
||||
<view class="bind-left">
|
||||
<view class="bind-icon phone-icon">📱</view>
|
||||
<view class="bind-icon phone-icon"><icon name="smartphone" size="40" color="#00CED1"></icon></view>
|
||||
<view class="bind-info">
|
||||
<text class="bind-label">手机号</text>
|
||||
<text class="bind-value">{{phoneNumber || '未绑定'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bind-right">
|
||||
<text class="bind-check" wx:if="{{phoneNumber}}">✓</text>
|
||||
<icon wx:if="{{phoneNumber}}" name="check" size="36" color="#34C759" customClass="bind-check"></icon>
|
||||
<button wx:else class="get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
|
||||
一键获取
|
||||
</button>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!-- 微信号 - 简化输入 -->
|
||||
<view class="bind-item">
|
||||
<view class="bind-left">
|
||||
<view class="bind-icon wechat-icon">💬</view>
|
||||
<view class="bind-icon wechat-icon"><icon name="message-circle" size="40" color="#00CED1"></icon></view>
|
||||
<view class="bind-info">
|
||||
<text class="bind-label">微信号</text>
|
||||
<input
|
||||
@@ -54,14 +54,14 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="bind-right">
|
||||
<text class="bind-check" wx:if="{{wechatId}}">✓</text>
|
||||
<icon wx:if="{{wechatId}}" name="check" size="36" color="#34C759" customClass="bind-check"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收货地址 - 跳转到地址管理页 -->
|
||||
<view class="bind-item" bindtap="goToAddresses">
|
||||
<view class="bind-left">
|
||||
<view class="bind-icon address-icon">📍</view>
|
||||
<view class="bind-icon address-icon"><icon name="map-pin" size="40" color="#00CED1"></icon></view>
|
||||
<view class="bind-info">
|
||||
<text class="bind-label">收货地址</text>
|
||||
<text class="bind-value address-text">管理收货地址,用于发货与邮寄</text>
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- 自动提现设置 -->
|
||||
<view class="bind-card auto-withdraw-card" wx:if="{{isLoggedIn && wechatId}}">
|
||||
<view class="card-header">
|
||||
<text class="card-icon">💰</text>
|
||||
<icon name="wallet" size="48" color="#00CED1" customClass="card-icon"></icon>
|
||||
<view class="card-title-wrap">
|
||||
<text class="card-title">自动提现</text>
|
||||
<text class="card-desc">收益自动打款到微信零钱</text>
|
||||
@@ -112,7 +112,7 @@
|
||||
<!-- 开发专用:切换账号(仅开发版显示) -->
|
||||
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="openSwitchAccountModal">
|
||||
<view class="dev-switch-inner">
|
||||
<text class="dev-switch-icon">🔧</text>
|
||||
<icon name="wrench" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
|
||||
<text class="dev-switch-text">切换账号(开发)</text>
|
||||
<text class="dev-switch-desc">输入 userId 切换为其他账号调试</text>
|
||||
</view>
|
||||
@@ -126,7 +126,7 @@
|
||||
<view class="modal-content" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">绑定{{bindType === 'phone' ? '手机号' : bindType === 'wechat' ? '微信号' : '支付宝'}}</text>
|
||||
<view class="modal-close" bindtap="closeBindModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeBindModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
|
||||
<view class="modal-body">
|
||||
@@ -158,7 +158,7 @@
|
||||
<view class="modal-content" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">切换账号(开发)</text>
|
||||
<view class="modal-close" bindtap="closeSwitchAccountModal">✕</view>
|
||||
<view class="modal-close" bindtap="closeSwitchAccountModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="input-wrapper">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const accessManager = require('../../utils/chapterAccessManager')
|
||||
import accessManager from '../../utils/chapterAccessManager'
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -13,16 +12,16 @@ Page({
|
||||
originalPrice: 6980,
|
||||
/* 按 premium_membership_landing_v1 设计稿 */
|
||||
contentRights: [
|
||||
{ title: '解锁章节', desc: '全部章节365天畅读', icon: '📖' },
|
||||
{ title: '创业项目', desc: '查看最新创业项目', icon: '📚' },
|
||||
{ title: '每日纪要', desc: '专属团队每日总结', icon: '💡' },
|
||||
{ title: '文内链接', desc: '文章提到你可被链接', icon: '📁' }
|
||||
{ title: '解锁全部章节', desc: '365天全案精读', icon: 'book-open' },
|
||||
{ title: '案例库', desc: '100+创业实战案例', icon: 'book-open' },
|
||||
{ title: '智能纪要', desc: 'AI每日精华推送', icon: 'lightbulb' },
|
||||
{ title: '会议纪要库', desc: '往期完整沉淀', icon: 'folder' }
|
||||
],
|
||||
socialRights: [
|
||||
{ title: '匹配伙伴', desc: '1980次创业伙伴匹配', icon: '👥' },
|
||||
{ title: '获得客资', desc: '加入创业伙伴获客资', icon: '🔗' },
|
||||
{ title: '老板排行', desc: '项目曝光超级个体', icon: '📊' },
|
||||
{ title: 'VIP标识', desc: '金色尊享光圈特权', icon: '✓' }
|
||||
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: 'users' },
|
||||
{ title: '创业老板排行', desc: '项目曝光展示', icon: 'bar-chart' },
|
||||
{ title: '链接资源', desc: '深度私域资源池', icon: 'link' },
|
||||
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: 'check' }
|
||||
],
|
||||
purchasing: false
|
||||
},
|
||||
@@ -66,7 +65,7 @@ Page({
|
||||
},
|
||||
|
||||
async handlePurchase() {
|
||||
trackClick('vip', 'btn_click', '购买VIP')
|
||||
trackClick('vip', 'btn_click', '开通VIP')
|
||||
let userId = app.globalData.userInfo?.id
|
||||
let openId = app.globalData.openId || app.globalData.userInfo?.open_id
|
||||
if (!userId || !openId) {
|
||||
@@ -87,7 +86,37 @@ Page({
|
||||
}
|
||||
}
|
||||
this.setData({ purchasing: true })
|
||||
const amount = this.data.price
|
||||
try {
|
||||
// 0. 尝试余额支付(若余额足够)
|
||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||
try {
|
||||
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
|
||||
const balance = balanceRes?.data?.balance || 0
|
||||
if (balance >= amount) {
|
||||
const consumeRes = await app.request({
|
||||
url: '/api/miniprogram/balance/consume',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
productType: 'vip',
|
||||
productId: 'vip_annual',
|
||||
amount,
|
||||
referralCode: referralCode || undefined
|
||||
}
|
||||
})
|
||||
if (consumeRes?.success) {
|
||||
this.setData({ purchasing: false })
|
||||
wx.showToast({ title: 'VIP开通成功', icon: 'success' })
|
||||
await this._onVipPaymentSuccess()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[VIP] 余额支付失败,改用微信支付:', e)
|
||||
}
|
||||
|
||||
// 1. 微信支付
|
||||
const payRes = await app.request('/api/miniprogram/pay', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -95,7 +124,7 @@ Page({
|
||||
userId,
|
||||
productType: 'vip',
|
||||
productId: 'vip_annual',
|
||||
amount: this.data.price,
|
||||
amount,
|
||||
description: '卡若创业派对VIP年度会员(365天)'
|
||||
}
|
||||
})
|
||||
@@ -129,17 +158,6 @@ Page({
|
||||
if (typeof p.initUserStatus === 'function') p.initUserStatus()
|
||||
else if (typeof p.updateUserStatus === 'function') p.updateUserStatus()
|
||||
})
|
||||
// 记录购买行为到 user_tracks
|
||||
const uid = app.globalData.userInfo?.id
|
||||
if (uid) {
|
||||
app.request('/api/miniprogram/track', {
|
||||
method: 'POST',
|
||||
data: { userId: uid, action: 'purchase', target: 'vip_annual', extraData: { amount: this.data.price } },
|
||||
silent: true
|
||||
}).catch(() => {})
|
||||
}
|
||||
// 购买后规则:引导填写完整信息
|
||||
checkAndExecute('after_pay', this)
|
||||
} catch (e) {
|
||||
console.error('[VIP] 支付后同步失败:', e)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="#ffffff" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">卡若创业派对VIP会员</text>
|
||||
<view class="nav-placeholder-r"></view>
|
||||
@@ -22,7 +22,7 @@
|
||||
<text class="rights-col-title">会员权利</text>
|
||||
</view>
|
||||
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
|
||||
<text class="benefit-icon">{{item.icon || '✓'}}</text>
|
||||
<icon name="{{item.icon || 'check'}}" size="40" color="#00CED1" customClass="benefit-icon"></icon>
|
||||
<view class="benefit-info">
|
||||
<text class="benefit-title">{{item.title}}</text>
|
||||
<text class="benefit-desc">{{item.desc}}</text>
|
||||
@@ -35,7 +35,7 @@
|
||||
<text class="rights-col-title rights-col-title-gold">派对权利</text>
|
||||
</view>
|
||||
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
|
||||
<text class="benefit-icon benefit-icon-gold">{{item.icon || '✓'}}</text>
|
||||
<icon name="{{item.icon || 'check'}}" size="40" color="#FFD700" customClass="benefit-icon benefit-icon-gold"></icon>
|
||||
<view class="benefit-info">
|
||||
<text class="benefit-title">{{item.title}}</text>
|
||||
<text class="benefit-desc">{{item.desc}}</text>
|
||||
|
||||
@@ -13,14 +13,22 @@ Page({
|
||||
loading: true,
|
||||
rechargeAmounts: [10, 30, 50, 1000],
|
||||
selectedAmount: 30,
|
||||
auditMode: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
auditMode: app.globalData.auditMode || false,
|
||||
})
|
||||
this.loadBalance()
|
||||
this.loadTransactions()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
},
|
||||
|
||||
async loadBalance() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
const userId = app.globalData.userInfo.id
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<!-- Soul创业派对 - 我的余额 -->
|
||||
<view class="page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">我的余额</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
<view class="nav-placeholder-block" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<!-- 余额卡片 -->
|
||||
<view class="balance-card">
|
||||
<view class="balance-main" wx:if="{{!loading}}">
|
||||
<text class="balance-label">当前余额</text>
|
||||
@@ -22,8 +20,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 充值金额选择 -->
|
||||
<view class="section">
|
||||
<view class="section" wx:if="{{!auditMode}}">
|
||||
<view class="section-head">
|
||||
<text class="section-title">选择充值金额</text>
|
||||
<text class="section-note">当前已选 ¥{{selectedAmount}}</text>
|
||||
@@ -47,33 +44,30 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="action-row">
|
||||
<view class="action-row" wx:if="{{!auditMode}}">
|
||||
<view class="btn btn-recharge" bindtap="handleRecharge">充值</view>
|
||||
</view>
|
||||
<view class="action-row" wx:elif="{{auditMode}}">
|
||||
<view class="audit-tip">审核中,暂不支持充值</view>
|
||||
</view>
|
||||
|
||||
<!-- 充值与消费记录 -->
|
||||
<view class="section">
|
||||
<view class="section-head">
|
||||
<text class="section-title">充值/消费记录</text>
|
||||
<text class="section-note">按时间倒序显示</text>
|
||||
</view>
|
||||
<view class="transactions" wx:if="{{transactions.length > 0}}">
|
||||
<view
|
||||
class="tx-item"
|
||||
wx:for="{{transactions}}"
|
||||
wx:key="id"
|
||||
>
|
||||
<view class="tx-item" wx:for="{{transactions}}" wx:key="id">
|
||||
<view class="tx-icon {{item.type}}">
|
||||
<text wx:if="{{item.type === 'recharge'}}">💰</text>
|
||||
<text wx:elif="{{item.type === 'gift'}}">🎁</text>
|
||||
<text wx:elif="{{item.type === 'refund'}}">↩️</text>
|
||||
<text wx:elif="{{item.type === 'consume'}}">📖</text>
|
||||
<icon wx:if="{{item.type === 'recharge'}}" name="wallet" size="36" color="#34C759"></icon>
|
||||
<icon wx:elif="{{item.type === 'gift'}}" name="gift" size="36" color="#00CED1"></icon>
|
||||
<icon wx:elif="{{item.type === 'refund'}}" name="corner-down-left" size="36" color="#ff9500"></icon>
|
||||
<icon wx:elif="{{item.type === 'consume'}}" name="book-open" size="36" color="#8e8e93"></icon>
|
||||
<text wx:else>•</text>
|
||||
</view>
|
||||
<view class="tx-info">
|
||||
<text class="tx-desc">{{item.description}}</text>
|
||||
<text class="tx-time">{{item.createdAt || item.created_at || '--'}}</text>
|
||||
<text class="tx-time">{{item.createdAt || '--'}}</text>
|
||||
</view>
|
||||
<text class="tx-amount {{item.amount >= 0 ? 'tx-amount-plus' : 'tx-amount-minus'}}">{{item.amountSign}}¥{{item.amountText}}</text>
|
||||
</view>
|
||||
|
||||
@@ -78,6 +78,12 @@
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.audit-tip {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.balance-skeleton {
|
||||
padding: 40rpx 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">←</view>
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">提现记录</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
|
||||
64
miniprogram/project.private.config.json
Normal file
64
miniprogram/project.private.config.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "miniprogram",
|
||||
"setting": {
|
||||
"compileHotReLoad": true,
|
||||
"urlCheck": true,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false,
|
||||
"useIsolateContext": true
|
||||
},
|
||||
"libVersion": "3.13.2",
|
||||
"condition": {
|
||||
"miniprogram": {
|
||||
"list": [
|
||||
{
|
||||
"name": "pages/gift-pay/list",
|
||||
"pathName": "pages/gift-pay/list",
|
||||
"query": "",
|
||||
"scene": null,
|
||||
"launchMode": "default"
|
||||
},
|
||||
{
|
||||
"name": "代付",
|
||||
"pathName": "pages/gift-pay/detail",
|
||||
"query": "requestSn=GPRMP20260317145140501100",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "唤醒",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=209",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/my/my",
|
||||
"pathName": "pages/my/my",
|
||||
"query": "",
|
||||
"launchMode": "singlePage",
|
||||
"scene": null
|
||||
},
|
||||
{
|
||||
"name": "pages/read/read",
|
||||
"pathName": "pages/read/read",
|
||||
"query": "mid=20",
|
||||
"launchMode": "default",
|
||||
"scene": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
121
miniprogram/static/iconfont.wxss
Normal file
121
miniprogram/static/iconfont.wxss
Normal file
@@ -0,0 +1,121 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 5142223 */
|
||||
/* 微信小程序里 url 带 query 可能导致找不到本地文件,统一去掉 */
|
||||
/* 使用从根目录开始的绝对路径(最稳) */
|
||||
src: url('/static/iconfont.woff2') format('woff2'),
|
||||
url('/static/iconfont.woff') format('woff'),
|
||||
url('/static/iconfont.ttf') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-qianbao:before { content: "\e6c8"; }
|
||||
.icon-gift:before { content: "\e6c9"; }
|
||||
.icon-zap1:before { content: "\e75c"; }
|
||||
.icon-user:before { content: "\e6b9"; }
|
||||
.icon-upload:before { content: "\e6ba"; }
|
||||
.icon-work:before { content: "\e6bb"; }
|
||||
.icon-training:before { content: "\e6bc"; }
|
||||
.icon-warning:before { content: "\e6bd"; }
|
||||
.icon-zoom-in:before { content: "\e6be"; }
|
||||
.icon-zoom-out:before { content: "\e6bf"; }
|
||||
.icon-arrow-left-bold:before { content: "\e6c1"; }
|
||||
.icon-arrow-up-bold:before { content: "\e6c2"; }
|
||||
.icon-close-bold:before { content: "\e6c3"; }
|
||||
.icon-arrow-down-bold:before { content: "\e6c4"; }
|
||||
.icon-minus-bold:before { content: "\e6c5"; }
|
||||
.icon-arrow-right-bold:before { content: "\e6c6"; }
|
||||
.icon-select-bold:before { content: "\e6c7"; }
|
||||
.icon-money-wallet:before { content: "\e833"; }
|
||||
.icon-book-open:before { content: "\e993"; }
|
||||
.icon-biaoshilei_yonghuzu:before { content: "\e61b"; }
|
||||
.icon-add:before { content: "\e664"; }
|
||||
.icon-add-circle:before { content: "\e665"; }
|
||||
.icon-adjust:before { content: "\e666"; }
|
||||
.icon-arrow-up-circle:before { content: "\e667"; }
|
||||
.icon-arrow-right-circle:before { content: "\e668"; }
|
||||
.icon-arrow-down:before { content: "\e669"; }
|
||||
.icon-ashbin:before { content: "\e66a"; }
|
||||
.icon-arrow-right:before { content: "\e66b"; }
|
||||
.icon-browse:before { content: "\e66c"; }
|
||||
.icon-bottom:before { content: "\e66d"; }
|
||||
.icon-back:before { content: "\e66e"; }
|
||||
.icon-bad:before { content: "\e66f"; }
|
||||
.icon-arrow-left-circle:before { content: "\e670"; }
|
||||
.icon-camera:before { content: "\e671"; }
|
||||
.icon-chart-bar:before { content: "\e672"; }
|
||||
.icon-attachment:before { content: "\e673"; }
|
||||
.icon-code:before { content: "\e674"; }
|
||||
.icon-close:before { content: "\e675"; }
|
||||
.icon-check-item:before { content: "\e676"; }
|
||||
.icon-calendar:before { content: "\e677"; }
|
||||
.icon-comment:before { content: "\e678"; }
|
||||
.icon-complete:before { content: "\e679"; }
|
||||
.icon-direction-down:before { content: "\e67a"; }
|
||||
.icon-direction-down-circle:before { content: "\e67b"; }
|
||||
.icon-direction-right:before { content: "\e67c"; }
|
||||
.icon-direction-up:before { content: "\e67d"; }
|
||||
.icon-discount:before { content: "\e67e"; }
|
||||
.icon-electronics:before { content: "\e681"; }
|
||||
.icon-elipsis:before { content: "\e682"; }
|
||||
.icon-export:before { content: "\e683"; }
|
||||
.icon-explain:before { content: "\e684"; }
|
||||
.icon-edit:before { content: "\e685"; }
|
||||
.icon-eye-close:before { content: "\e686"; }
|
||||
.icon-email:before { content: "\e687"; }
|
||||
.icon-error:before { content: "\e688"; }
|
||||
.icon-favorite:before { content: "\e689"; }
|
||||
.icon-file-common:before { content: "\e68a"; }
|
||||
.icon-file-delete:before { content: "\e68b"; }
|
||||
.icon-file-add:before { content: "\e68c"; }
|
||||
.icon-film:before { content: "\e68d"; }
|
||||
.icon-fabulous:before { content: "\e68e"; }
|
||||
.icon-file:before { content: "\e68f"; }
|
||||
.icon-folder-close:before { content: "\e690"; }
|
||||
.icon-filter:before { content: "\e691"; }
|
||||
.icon-good:before { content: "\e692"; }
|
||||
.icon-hide:before { content: "\e693"; }
|
||||
.icon-home:before { content: "\e694"; }
|
||||
.icon-file-open:before { content: "\e695"; }
|
||||
.icon-forward:before { content: "\e696"; }
|
||||
.icon-import:before { content: "\e697"; }
|
||||
.icon-layers:before { content: "\e698"; }
|
||||
.icon-lock:before { content: "\e699"; }
|
||||
.icon-map:before { content: "\e69a"; }
|
||||
.icon-menu:before { content: "\e69b"; }
|
||||
.icon-help:before { content: "\e69c"; }
|
||||
.icon-minus-circle:before { content: "\e69d"; }
|
||||
.icon-notification:before { content: "\e69e"; }
|
||||
.icon-more:before { content: "\e69f"; }
|
||||
.icon-mobile-phone:before { content: "\e6a0"; }
|
||||
.icon-minus:before { content: "\e6a1"; }
|
||||
.icon-navigation:before { content: "\e6a2"; }
|
||||
.icon-prompt:before { content: "\e6a3"; }
|
||||
.icon-refresh:before { content: "\e6a4"; }
|
||||
.icon-run-up:before { content: "\e6a5"; }
|
||||
.icon-picture:before { content: "\e6a6"; }
|
||||
.icon-run-in:before { content: "\e6a7"; }
|
||||
.icon-pin:before { content: "\e6a8"; }
|
||||
.icon-save:before { content: "\e6a9"; }
|
||||
.icon-search:before { content: "\e6aa"; }
|
||||
.icon-share:before { content: "\e6ab"; }
|
||||
.icon-scanning:before { content: "\e6ac"; }
|
||||
.icon-security:before { content: "\e6ad"; }
|
||||
.icon-sign-out:before { content: "\e6ae"; }
|
||||
.icon-select:before { content: "\e6af"; }
|
||||
.icon-stop:before { content: "\e6b0"; }
|
||||
.icon-success:before { content: "\e6b1"; }
|
||||
.icon-switch:before { content: "\e6b2"; }
|
||||
.icon-setting:before { content: "\e6b3"; }
|
||||
.icon-survey:before { content: "\e6b4"; }
|
||||
.icon-time:before { content: "\e6b5"; }
|
||||
.icon-telephone:before { content: "\e6b6"; }
|
||||
.icon-top:before { content: "\e6b7"; }
|
||||
.icon-unlock:before { content: "\e6b8"; }
|
||||
|
||||
@@ -22,8 +22,8 @@ class ChapterAccessManager {
|
||||
*/
|
||||
async fetchLatestConfig() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
|
||||
if (res.success && res.prices) {
|
||||
const res = await app.getConfig()
|
||||
if (res && res.success && res.prices) {
|
||||
return {
|
||||
prices: res.prices || { section: 1, fullbook: 9.9 }
|
||||
}
|
||||
|
||||
210
miniprogram/yulan.html
Normal file
210
miniprogram/yulan.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>代付页面预览 - Premium FriendPay</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;900&family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #050505;
|
||||
color: white;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 8s infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
// Simple Icon component to wrap Lucide
|
||||
const Icon = ({ name, className }) => {
|
||||
useEffect(() => {
|
||||
lucide.createIcons();
|
||||
}, [name]);
|
||||
return <i data-lucide={name} className={className}></i>;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [viewMode, setViewMode] = useState('payer');
|
||||
|
||||
const content = {
|
||||
payer: {
|
||||
title: '帮他付款',
|
||||
productName: 'AI 提效实战课:从入门到精通',
|
||||
productDesc: '第 123 场直播回放 · 包含所有课件与实战案例',
|
||||
requesterName: '好**',
|
||||
requesterMsg: '“ 这门课对我很有帮助,希望能帮我代付一下,非常感谢! ”',
|
||||
amount: '199.00',
|
||||
buttonText: '立即帮他付款',
|
||||
buttonIcon: 'credit-card',
|
||||
},
|
||||
requester: {
|
||||
title: '找朋友代付',
|
||||
productName: '3000万流水如何跑出来 (退税模式解析)',
|
||||
productDesc: '深度解析企业退税合规与流水结构优化',
|
||||
requesterName: '你自己',
|
||||
requesterMsg: '分享给好友,好友打开后即可为你完成支付。',
|
||||
amount: '299.00',
|
||||
buttonText: '发送给好友',
|
||||
buttonIcon: 'share-2',
|
||||
}
|
||||
};
|
||||
|
||||
const current = content[viewMode];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col relative overflow-x-hidden">
|
||||
{/* View Switcher */}
|
||||
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-50 flex bg-zinc-900/80 backdrop-blur-xl rounded-full p-1 border border-white/5 shadow-2xl">
|
||||
<button
|
||||
onClick={() => setViewMode('payer')}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold tracking-wide transition-all duration-300 ${viewMode === 'payer' ? 'bg-[#14b8a6] text-black shadow-[0_0_15px_rgba(20,184,166,0.4)]' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
代付视角
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('requester')}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold tracking-wide transition-all duration-300 ${viewMode === 'requester' ? 'bg-[#14b8a6] text-black shadow-[0_0_15px_rgba(20,184,166,0.4)]' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
发起视角
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto w-full min-h-screen flex flex-col relative">
|
||||
{/* Navigation */}
|
||||
<header className="px-6 py-8 flex items-center justify-between sticky top-0 bg-[#050505]/60 backdrop-blur-xl z-10">
|
||||
<button className="w-10 h-10 flex items-center justify-center bg-zinc-900/50 border border-white/5 rounded-full hover:bg-zinc-800 transition-colors">
|
||||
<Icon name="chevron-left" className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-sm font-bold uppercase tracking-[0.2em] text-zinc-400">{current.title}</h1>
|
||||
<div className="flex items-center gap-2 bg-zinc-900/50 border border-white/5 rounded-full px-3 py-1.5">
|
||||
<Icon name="more-horizontal" className="w-4 h-4 text-zinc-500" />
|
||||
<div className="w-[1px] h-3 bg-white/10" />
|
||||
<Icon name="circle" className="w-3 h-3 fill-white text-white" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-6 pb-40 space-y-8">
|
||||
{/* Product Hero Card */}
|
||||
<section className="relative group">
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-b from-[#14b8a6]/20 to-transparent rounded-[2rem] blur-xl opacity-50"></div>
|
||||
<div className="relative bg-zinc-900/80 border border-white/10 rounded-[2rem] p-8 overflow-hidden transition-all duration-500 hover:border-[#14b8a6]/30">
|
||||
<div className="absolute top-0 right-0 p-6 opacity-10">
|
||||
<Icon name="info" className="w-12 h-12" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-[#14b8a6]/10 border border-[#14b8a6]/20 text-[#14b8a6] text-[10px] font-black uppercase tracking-widest">
|
||||
订单详情
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-tight">
|
||||
{current.productName}
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-sm font-medium">
|
||||
{current.productDesc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-white/5 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-zinc-500 font-bold">应付金额</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[#14b8a6] text-lg font-bold">¥</span>
|
||||
<span className="text-3xl font-mono font-bold tracking-tighter">{current.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#14b8a6]/10 border border-[#14b8a6]/20 flex items-center justify-center">
|
||||
<Icon name="arrow-right" className="w-5 h-5 text-[#14b8a6]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requester Info */}
|
||||
<section className="bg-zinc-900/30 border border-white/5 rounded-3xl p-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-zinc-700 to-zinc-900 border border-white/10 flex items-center justify-center shadow-inner">
|
||||
<Icon name="user" className="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-zinc-200">{current.requesterName}</h4>
|
||||
<p className="text-[10px] uppercase tracking-widest text-zinc-500 font-bold">发起代付请求</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#14b8a6]/30 rounded-full" />
|
||||
<p className="pl-5 text-zinc-400 text-sm italic leading-relaxed">
|
||||
{current.requesterMsg}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Badge */}
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
<Icon name="shield-check" className="w-4 h-4 text-[#14b8a6]/60" />
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-zinc-600 font-bold">
|
||||
安全支付保障 · 资金由平台托管
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Floating Action Bar */}
|
||||
<footer className="fixed bottom-0 left-0 right-0 p-8 z-20">
|
||||
<div className="max-w-md mx-auto relative">
|
||||
<div className="absolute inset-0 bg-zinc-900/80 backdrop-blur-2xl border border-white/10 rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)]" />
|
||||
|
||||
<div className="relative p-3 flex items-center gap-4">
|
||||
<div className="flex-1 pl-6">
|
||||
<p className="text-[10px] uppercase tracking-widest text-zinc-500 font-bold mb-0.5">合计</p>
|
||||
<p className="text-xl font-mono font-bold tracking-tighter">
|
||||
<span className="text-[#14b8a6] text-sm mr-1">¥</span>
|
||||
{current.amount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="bg-[#14b8a6] hover:bg-[#0d9488] text-black font-black px-8 py-4 rounded-[1.8rem] flex items-center justify-center transition-all shadow-[0_8px_20px_rgba(20,184,166,0.3)] active:scale-95"
|
||||
>
|
||||
<Icon name={current.buttonIcon} className="w-5 h-5 mr-2" />
|
||||
<span className="text-sm uppercase tracking-wider">{current.buttonText}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Ambient Background Effects */}
|
||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none -z-10">
|
||||
<div className="absolute top-[-20%] left-[-10%] w-[80%] h-[60%] bg-[#14b8a6]/5 blur-[150px] rounded-full animate-pulse-slow" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[60%] h-[50%] bg-[#14b8a6]/5 blur-[120px] rounded-full" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full opacity-[0.02] bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:32px_32px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,6 +14,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 响应速度测试
|
||||
|
||||
`test_article_preview_speed.py`:文章阅读与界面预览 GET 接口响应速度测试。
|
||||
|
||||
```bash
|
||||
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
|
||||
```
|
||||
|
||||
产出:控制台报表 + `开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md`
|
||||
|
||||
---
|
||||
|
||||
## 用例编写
|
||||
|
||||
在此目录下新增 `.md` 或测试脚本,按场景组织用例。
|
||||
|
||||
234
scripts/test/miniapp/test_article_preview_speed.py
Normal file
234
scripts/test/miniapp/test_article_preview_speed.py
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
文章阅读与界面预览 GET 接口响应速度测试
|
||||
|
||||
测试范围:
|
||||
- 界面预览:config、book/parts、book/all-chapters、book/chapters-by-part
|
||||
- 文章阅读:book/chapter/:id、book/chapter/by-mid/:mid
|
||||
|
||||
用法:
|
||||
SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py
|
||||
SOUL_TEST_ENV=soulapi python -m scripts.test.miniapp.test_article_preview_speed
|
||||
|
||||
产出:控制台报表 + 开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# 加载测试配置
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from config import API_BASE, ENV_LABEL, get_env_banner
|
||||
|
||||
# 每接口请求次数(取平均)
|
||||
ROUNDS = 5
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def measure_get(url: str, desc: str) -> dict:
|
||||
"""对 GET 请求测速,返回 {ok, status_code, times_ms, avg_ms, min_ms, max_ms, error}"""
|
||||
times_ms = []
|
||||
last_error = None
|
||||
last_status = None
|
||||
for _ in range(ROUNDS):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
r = requests.get(url, timeout=TIMEOUT)
|
||||
last_status = r.status_code
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times_ms.append(elapsed)
|
||||
if r.status_code != 200:
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
except requests.RequestException as e:
|
||||
last_error = str(e)
|
||||
times_ms.append(-1)
|
||||
if not times_ms:
|
||||
return {"ok": False, "error": last_error or "无响应", "status_code": last_status}
|
||||
valid = [t for t in times_ms if t >= 0]
|
||||
return {
|
||||
"ok": len(valid) == ROUNDS and (last_status or 200) == 200,
|
||||
"status_code": last_status,
|
||||
"times_ms": times_ms,
|
||||
"avg_ms": sum(valid) / len(valid) if valid else 0,
|
||||
"min_ms": min(valid) if valid else 0,
|
||||
"max_ms": max(valid) if valid else 0,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print(get_env_banner())
|
||||
base = API_BASE.rstrip("/")
|
||||
|
||||
# 1. 先拉取 parts 和 all-chapters,获取 partId、id、mid
|
||||
parts_url = f"{base}/api/miniprogram/book/parts"
|
||||
all_chapters_url = f"{base}/api/miniprogram/book/all-chapters"
|
||||
|
||||
parts_data = None
|
||||
all_chapters_data = None
|
||||
try:
|
||||
r = requests.get(parts_url, timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
parts_data = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = requests.get(all_chapters_url, timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
all_chapters_data = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
part_id = None
|
||||
chapter_id = None
|
||||
chapter_mid = None
|
||||
if parts_data and parts_data.get("success"):
|
||||
parts = parts_data.get("parts") or []
|
||||
fixed = parts_data.get("fixedSections") or []
|
||||
if parts:
|
||||
part_id = parts[0].get("id")
|
||||
if fixed:
|
||||
chapter_mid = fixed[0].get("mid")
|
||||
chapter_id = fixed[0].get("id")
|
||||
if (not chapter_id or not chapter_mid) and all_chapters_data and all_chapters_data.get("success"):
|
||||
arr = all_chapters_data.get("data") or all_chapters_data.get("chapters") or []
|
||||
if arr:
|
||||
first = arr[0] if isinstance(arr[0], dict) else {}
|
||||
chapter_id = chapter_id or first.get("id")
|
||||
chapter_mid = chapter_mid or first.get("mid")
|
||||
if not part_id and parts_data and parts_data.get("success"):
|
||||
parts = parts_data.get("parts") or []
|
||||
if parts:
|
||||
part_id = parts[0].get("id")
|
||||
|
||||
# 2. 定义测试用例(仅 GET)
|
||||
cases = [
|
||||
("界面预览-配置", f"{base}/api/miniprogram/config", "GET /api/miniprogram/config"),
|
||||
("界面预览-目录", f"{base}/api/miniprogram/book/parts", "GET /api/miniprogram/book/parts"),
|
||||
("界面预览-全书章节", f"{base}/api/miniprogram/book/all-chapters", "GET /api/miniprogram/book/all-chapters"),
|
||||
]
|
||||
if part_id:
|
||||
cases.append(
|
||||
(
|
||||
"界面预览-篇章内章节",
|
||||
f"{base}/api/miniprogram/book/chapters-by-part?partId={part_id}",
|
||||
f"GET /api/miniprogram/book/chapters-by-part?partId={part_id}",
|
||||
)
|
||||
)
|
||||
if chapter_id:
|
||||
cases.append(
|
||||
(
|
||||
"文章阅读-按id",
|
||||
f"{base}/api/miniprogram/book/chapter/{chapter_id}",
|
||||
f"GET /api/miniprogram/book/chapter/:id",
|
||||
)
|
||||
)
|
||||
if chapter_mid:
|
||||
cases.append(
|
||||
(
|
||||
"文章阅读-按mid",
|
||||
f"{base}/api/miniprogram/book/chapter/by-mid/{chapter_mid}",
|
||||
f"GET /api/miniprogram/book/chapter/by-mid/:mid",
|
||||
)
|
||||
)
|
||||
|
||||
# 3. 执行测速
|
||||
results = []
|
||||
for name, url, api_desc in cases:
|
||||
print(f"\n测速: {name} ({api_desc})")
|
||||
res = measure_get(url, name)
|
||||
res["name"] = name
|
||||
res["api"] = api_desc
|
||||
res["url"] = url
|
||||
results.append(res)
|
||||
if res["ok"]:
|
||||
print(f" [OK] avg={res['avg_ms']:.0f}ms (min={res['min_ms']:.0f}, max={res['max_ms']:.0f})")
|
||||
else:
|
||||
print(f" [FAIL] {res.get('error', res.get('status_code', '?'))}")
|
||||
|
||||
# 4. 生成报表
|
||||
from datetime import datetime
|
||||
|
||||
date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
date_file = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
lines = [
|
||||
"# 文章阅读与界面预览 GET 接口响应速度测试报告",
|
||||
"",
|
||||
f"**测试时间**: {date_str}",
|
||||
f"**测试环境**: {ENV_LABEL} ({API_BASE})",
|
||||
f"**每接口请求次数**: {ROUNDS}",
|
||||
"",
|
||||
"## 一、测试范围",
|
||||
"",
|
||||
"| 分类 | 接口 | 说明 |",
|
||||
"|------|------|------|",
|
||||
"| 界面预览 | GET /api/miniprogram/config | 配置(价格、功能开关等) |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/parts | 目录-篇章列表 |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/all-chapters | 全书章节列表 |",
|
||||
"| 界面预览 | GET /api/miniprogram/book/chapters-by-part | 篇章内章节列表 |",
|
||||
"| 文章阅读 | GET /api/miniprogram/book/chapter/:id | 按业务 id 获取章节内容 |",
|
||||
"| 文章阅读 | GET /api/miniprogram/book/chapter/by-mid/:mid | 按 mid 获取章节内容 |",
|
||||
"",
|
||||
"## 二、响应速度结果",
|
||||
"",
|
||||
"| 接口 | 状态 | 平均(ms) | 最小(ms) | 最大(ms) |",
|
||||
"|------|------|----------|----------|----------|",
|
||||
]
|
||||
|
||||
for r in results:
|
||||
status = "OK" if r["ok"] else "FAIL"
|
||||
avg = f"{r['avg_ms']:.0f}" if r["ok"] else "-"
|
||||
min_ms = f"{r['min_ms']:.0f}" if r["ok"] else "-"
|
||||
max_ms = f"{r['max_ms']:.0f}" if r["ok"] else "-"
|
||||
if not r["ok"]:
|
||||
err = r.get("error", "") or f"HTTP {r.get('status_code', '?')}"
|
||||
avg = err[:20] if err else "-"
|
||||
lines.append(f"| {r['api']} | {status} | {avg} | {min_ms} | {max_ms} |")
|
||||
|
||||
# 汇总
|
||||
ok_count = sum(1 for r in results if r["ok"])
|
||||
total_count = len(results)
|
||||
if ok_count == total_count:
|
||||
avg_all = sum(r["avg_ms"] for r in results) / total_count
|
||||
lines.extend([
|
||||
"",
|
||||
"## 三、汇总",
|
||||
"",
|
||||
f"- 通过: {ok_count}/{total_count}",
|
||||
f"- 全部接口平均响应: {avg_all:.0f}ms",
|
||||
"",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
"",
|
||||
"## 三、汇总",
|
||||
"",
|
||||
f"- 通过: {ok_count}/{total_count}",
|
||||
f"- 失败: {total_count - ok_count} 个接口",
|
||||
"",
|
||||
])
|
||||
|
||||
report_content = "\n".join(lines)
|
||||
|
||||
# 5. 输出到控制台
|
||||
print("\n" + "=" * 60)
|
||||
print(report_content)
|
||||
print("=" * 60)
|
||||
|
||||
# 6. 写入文件(项目根/开发文档)
|
||||
report_dir = Path(__file__).resolve().parent.parent.parent.parent / "开发文档"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = report_dir / f"测试报告-文章阅读与界面预览响应速度-{date_file}.md"
|
||||
report_path.write_text(report_content, encoding="utf-8")
|
||||
print(f"\n报表已保存: {report_path}")
|
||||
|
||||
return 0 if ok_count == total_count else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
914
soul-admin/dist/assets/index-DyqIjjBz.js
vendored
Normal file
914
soul-admin/dist/assets/index-DyqIjjBz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-o3d5k2lQ.css
vendored
Normal file
1
soul-admin/dist/assets/index-o3d5k2lQ.css
vendored
Normal file
File diff suppressed because one or more lines are too long
26
soul-admin/dist/index.html
vendored
26
soul-admin/dist/index.html
vendored
@@ -1,13 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-BJTFaSuJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-dmhT0dvT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-DyqIjjBz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-o3d5k2lQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -347,8 +347,8 @@ export function ChapterTree({
|
||||
)
|
||||
}
|
||||
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放
|
||||
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放(以 part_id 识别,标题来自 DB)
|
||||
const is2026Daily = part.id === 'part-2026-daily'
|
||||
if (is2026Daily) {
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
return (
|
||||
|
||||
@@ -158,32 +158,21 @@ function buildTree(sections: SectionListItem[]): Part[] {
|
||||
hotRank: s.hotRank ?? 0,
|
||||
})
|
||||
}
|
||||
// 确保「2026每日派对干货」篇章存在(不在第六篇编号体系内)
|
||||
const DAILY_PART_ID = 'part-2026-daily'
|
||||
const DAILY_PART_TITLE = '2026每日派对干货'
|
||||
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
|
||||
if (!hasDailyPart) {
|
||||
partMap.set(DAILY_PART_ID, {
|
||||
id: DAILY_PART_ID,
|
||||
title: DAILY_PART_TITLE,
|
||||
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
|
||||
})
|
||||
}
|
||||
const parts = Array.from(partMap.values()).map((p) => ({
|
||||
...p,
|
||||
chapters: Array.from(p.chapters.values()),
|
||||
}))
|
||||
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
|
||||
const orderKey = (t: string) => {
|
||||
if (t.includes('序言')) return 0
|
||||
if (t.includes(DAILY_PART_TITLE)) return 1.5
|
||||
if (t.includes('附录')) return 2
|
||||
if (t.includes('尾声')) return 3
|
||||
// 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB
|
||||
const orderKey = (p: { id: string; title: string }) => {
|
||||
if (p.title.includes('序言')) return 0
|
||||
if (p.id === 'part-2026-daily') return 1.5
|
||||
if (p.title.includes('附录')) return 2
|
||||
if (p.title.includes('尾声')) return 3
|
||||
return 1
|
||||
}
|
||||
return parts.sort((a, b) => {
|
||||
const ka = orderKey(a.title)
|
||||
const kb = orderKey(b.title)
|
||||
const ka = orderKey(a)
|
||||
const kb = orderKey(b)
|
||||
if (ka !== kb) return ka - kb
|
||||
return 0
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { normalizeImageUrl } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
|
||||
@@ -62,13 +61,6 @@ interface OrdersRes {
|
||||
total?: number
|
||||
}
|
||||
|
||||
function maskPhone(phone?: string) {
|
||||
if (!phone) return ''
|
||||
const digits = phone.replace(/\s+/g, '')
|
||||
if (digits.length < 7) return digits
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
@@ -80,18 +72,17 @@ export function DashboardPage() {
|
||||
const [paidOrderCount, setPaidOrderCount] = useState(0)
|
||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||
const [conversionRate, setConversionRate] = useState(0)
|
||||
const [giftedTotal, setGiftedTotal] = useState(0)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
|
||||
const [giftedTotal, setGiftedTotal] = useState(0)
|
||||
const [ordersExpanded, setOrdersExpanded] = useState(false)
|
||||
const [trackPeriod, setTrackPeriod] = useState<string>('week')
|
||||
const [trackStats, setTrackStats] = useState<{
|
||||
total: number
|
||||
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
|
||||
} | null>(null)
|
||||
const [trackLoading, setTrackLoading] = useState(false)
|
||||
const [ordersExpanded, setOrdersExpanded] = useState(false)
|
||||
|
||||
const showError = (err: unknown) => {
|
||||
const e = err as Error & { status?: number; name?: string }
|
||||
@@ -149,7 +140,7 @@ export function DashboardPage() {
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
|
||||
'/api/admin/dashboard/recent-orders',
|
||||
'/api/admin/dashboard/recent-orders?limit=10',
|
||||
init
|
||||
)
|
||||
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
|
||||
@@ -160,7 +151,7 @@ export function DashboardPage() {
|
||||
const ordersData = await get<OrdersRes>('/api/admin/orders?page=1&pageSize=20&status=paid', init)
|
||||
const orders = ordersData?.orders ?? []
|
||||
const paid = orders.filter((p) => ['paid', 'completed', 'success'].includes(p.status || ''))
|
||||
setPurchases(paid.slice(0, 10))
|
||||
setPurchases(paid.slice(0, 5))
|
||||
} catch {
|
||||
setPurchases([])
|
||||
}
|
||||
@@ -230,6 +221,17 @@ export function DashboardPage() {
|
||||
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
|
||||
return { title: `余额充值 ¥${amount}`, subtitle: '余额充值' }
|
||||
}
|
||||
if (type === 'gift_pay') {
|
||||
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
|
||||
return { title: `代付 ¥${amount}`, subtitle: '好友代付' }
|
||||
}
|
||||
if (type === 'gift_pay_batch') {
|
||||
const amount = typeof p.amount === 'number' ? p.amount.toFixed(2) : parseFloat(String(p.amount || '0')).toFixed(2)
|
||||
return { title: desc || `代付分享 ¥${amount}`, subtitle: '代付分享' }
|
||||
}
|
||||
if (type === 'section' && desc.includes('代付领取')) {
|
||||
return { title: desc.replace('代付领取 - ', ''), subtitle: '代付领取' }
|
||||
}
|
||||
if (desc) {
|
||||
if (type === 'section' && desc.includes('章节')) {
|
||||
if (desc.includes('-')) {
|
||||
@@ -294,12 +296,12 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
value: statsLoading ? null : `${conversionRate.toFixed(1)}%`,
|
||||
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
|
||||
sub: null as string | null,
|
||||
icon: TrendingUp,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-500/20',
|
||||
link: '/users',
|
||||
icon: BookOpen,
|
||||
color: 'text-orange-400',
|
||||
bg: 'bg-orange-500/20',
|
||||
link: '/distribution',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -318,7 +320,7 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -371,7 +373,7 @@ export function DashboardPage() {
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
刷新
|
||||
刷新(每 30 秒自动更新)
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -398,7 +400,6 @@ export function DashboardPage() {
|
||||
const buyer =
|
||||
p.userNickname ||
|
||||
users.find((u) => u.id === p.userId)?.nickname ||
|
||||
maskPhone(users.find((u) => u.id === p.userId)?.phone) ||
|
||||
'匿名用户'
|
||||
|
||||
return (
|
||||
@@ -409,9 +410,9 @@ export function DashboardPage() {
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{p.userAvatar ? (
|
||||
<img
|
||||
src={normalizeImageUrl(p.userAvatar)}
|
||||
src={p.userAvatar}
|
||||
alt={buyer}
|
||||
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
|
||||
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const next = e.currentTarget.nextElementSibling as HTMLElement
|
||||
@@ -420,7 +421,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||||
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||||
>
|
||||
{buyer.charAt(0)}
|
||||
</div>
|
||||
@@ -435,7 +436,7 @@ export function DashboardPage() {
|
||||
{buyer}
|
||||
</button>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-sm font-medium text-white truncate" title={product.title}>
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{product.title}
|
||||
</span>
|
||||
</div>
|
||||
@@ -460,7 +461,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4 shrink-0">
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<p className="text-sm font-bold text-[#38bdac]">
|
||||
+¥{Number(p.amount).toFixed(2)}
|
||||
</p>
|
||||
@@ -471,22 +472,21 @@ export function DashboardPage() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{purchases.length > 4 && !ordersExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrdersExpanded(true)}
|
||||
className="w-full py-2 text-sm text-[#38bdac] hover:text-[#2da396] border border-dashed border-gray-600 rounded-lg hover:border-[#38bdac]/50 transition-colors"
|
||||
>
|
||||
展开更多
|
||||
</button>
|
||||
)}
|
||||
{purchases.length === 0 && !ordersLoading && (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无订单数据</p>
|
||||
</div>
|
||||
)}
|
||||
{purchases.length > 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrdersExpanded(!ordersExpanded)}
|
||||
className="w-full py-2 text-xs text-gray-400 hover:text-[#38bdac] transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${ordersExpanded ? 'rotate-90' : 'rotate-270'}`} />
|
||||
{ordersExpanded ? '收起' : `展开更多(共 ${purchases.length} 条)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -509,13 +509,13 @@ export function DashboardPage() {
|
||||
{users
|
||||
.slice(0, 5)
|
||||
.map((u) => (
|
||||
<div
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{(u.nickname || maskPhone(u.phone) || '?').charAt(0)}
|
||||
{u.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@@ -523,9 +523,9 @@ export function DashboardPage() {
|
||||
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
|
||||
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
|
||||
>
|
||||
{u.nickname || maskPhone(u.phone) || '匿名用户'}
|
||||
{u.nickname || '匿名用户'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">{maskPhone(u.phone) || '未填写手机号'}</p>
|
||||
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
@@ -545,8 +545,7 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 分类标签点击统计 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mt-8">
|
||||
<Card className="mt-8 bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#38bdac]" />
|
||||
|
||||
@@ -154,6 +154,8 @@ export function DistributionPage() {
|
||||
productType: string
|
||||
productId: string
|
||||
amount: number
|
||||
quantity?: number
|
||||
redeemedCount?: number
|
||||
description: string
|
||||
status: string
|
||||
payerUserId?: string
|
||||
@@ -1133,7 +1135,8 @@ export function DistributionPage() {
|
||||
onChange={(e) => { setGiftPayStatusFilter(e.target.value); setGiftPayPage(1) }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待支付</option>
|
||||
<option value="pending">待支付(旧)</option>
|
||||
<option value="pending_pay">待发起人支付</option>
|
||||
<option value="paid">已支付</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="expired">已过期</option>
|
||||
@@ -1153,7 +1156,8 @@ export function DistributionPage() {
|
||||
<th className="p-4 text-left font-medium text-gray-400">请求号</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">发起人</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">商品/金额</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">代付人</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">份数/已领</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">付款人</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">状态</th>
|
||||
<th className="p-4 text-left font-medium text-gray-400">创建时间</th>
|
||||
</tr>
|
||||
@@ -1169,18 +1173,21 @@ export function DistributionPage() {
|
||||
<p className="text-white">{r.productType} · ¥{r.amount.toFixed(2)}</p>
|
||||
{r.description && <p className="text-gray-500 text-xs">{r.description}</p>}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{(r.quantity ?? 1) > 1 ? `${r.quantity}份 / 已领${r.redeemedCount ?? 0}` : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">{r.payerNick || (r.payerUserId ? r.payerUserId : '-')}</td>
|
||||
<td className="p-4">
|
||||
<Badge
|
||||
className={
|
||||
r.status === 'paid'
|
||||
? 'bg-green-500/20 text-green-400 border-0'
|
||||
: r.status === 'pending'
|
||||
: r.status === 'pending' || r.status === 'pending_pay'
|
||||
? 'bg-amber-500/20 text-amber-400 border-0'
|
||||
: 'bg-gray-500/20 text-gray-400 border-0'
|
||||
}
|
||||
>
|
||||
{r.status === 'paid' ? '已支付' : r.status === 'pending' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
|
||||
{r.status === 'paid' ? '已支付' : r.status === 'pending' || r.status === 'pending_pay' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-sm">
|
||||
|
||||
@@ -122,7 +122,10 @@ export function OrdersPage() {
|
||||
return { name: `余额充值 ¥${amount}`, type: '余额充值' }
|
||||
}
|
||||
if (desc) {
|
||||
if (type === 'section' && desc.includes('章节')) {
|
||||
if (type === 'section' && (desc.includes('章节') || desc.includes('代付领取'))) {
|
||||
if (desc.includes('代付领取')) {
|
||||
return { name: desc.replace('代付领取 - ', ''), type: '代付领取' }
|
||||
}
|
||||
if (desc.includes('-')) {
|
||||
const parts = desc.split('-')
|
||||
if (parts.length >= 3) {
|
||||
@@ -314,7 +317,12 @@ export function OrdersPage() {
|
||||
<div>
|
||||
<p className="text-white text-sm flex items-center gap-2">
|
||||
{getUserNickname(purchase)}
|
||||
{purchase.payerUserId && (
|
||||
{purchase.paymentMethod === 'gift_pay' && (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/20 border-0 text-xs">
|
||||
代付领取
|
||||
</Badge>
|
||||
)}
|
||||
{purchase.payerUserId && purchase.paymentMethod !== 'gift_pay' && (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
|
||||
代付
|
||||
</Badge>
|
||||
@@ -322,7 +330,9 @@ export function OrdersPage() {
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
|
||||
{purchase.payerUserId && purchase.payerNickname && (
|
||||
<p className="text-amber-400/80 text-xs mt-0.5">代付人:{purchase.payerNickname}</p>
|
||||
<p className="text-amber-400/80 text-xs mt-0.5">
|
||||
{purchase.paymentMethod === 'gift_pay' ? '赠送人:' : '代付人:'}{purchase.payerNickname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Smartphone,
|
||||
ShieldCheck,
|
||||
Link2,
|
||||
FileText,
|
||||
Cloud,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
@@ -75,10 +76,10 @@ interface MpConfig {
|
||||
|
||||
interface OssConfig {
|
||||
endpoint?: string
|
||||
accessKeyId?: string
|
||||
accessKeySecret?: string
|
||||
bucket?: string
|
||||
region?: string
|
||||
accessKeyId?: string
|
||||
accessKeySecret?: string
|
||||
}
|
||||
|
||||
const defaultMpConfig: MpConfig = {
|
||||
@@ -105,14 +106,6 @@ const defaultSettings: LocalSettings = {
|
||||
ckbLeadApiKey: '',
|
||||
}
|
||||
|
||||
const defaultOssConfig: OssConfig = {
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
bucket: '',
|
||||
region: '',
|
||||
}
|
||||
|
||||
const defaultFeatures: FeatureConfig = {
|
||||
matchEnabled: true,
|
||||
referralEnabled: true,
|
||||
@@ -131,7 +124,7 @@ export function SettingsPage() {
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
const [ossConfig, setOssConfig] = useState<OssConfig>(defaultOssConfig)
|
||||
const [ossConfig, setOssConfig] = useState<OssConfig>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -223,7 +216,7 @@ export function SettingsPage() {
|
||||
setAuditModeSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||||
mp_config: next,
|
||||
mpConfig: next,
|
||||
})
|
||||
if (!res || (res as { success?: boolean }).success === false) {
|
||||
setMpConfig(prev)
|
||||
@@ -257,14 +250,17 @@ export function SettingsPage() {
|
||||
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
|
||||
mchId: mpConfig.mchId || '',
|
||||
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
|
||||
auditMode: mpConfig.auditMode ?? false,
|
||||
},
|
||||
ossConfig: {
|
||||
endpoint: ossConfig.endpoint || '',
|
||||
accessKeyId: ossConfig.accessKeyId || '',
|
||||
accessKeySecret: ossConfig.accessKeySecret || '',
|
||||
bucket: ossConfig.bucket || '',
|
||||
region: ossConfig.region || '',
|
||||
},
|
||||
ossConfig: Object.keys(ossConfig).length
|
||||
? {
|
||||
endpoint: ossConfig.endpoint ?? '',
|
||||
bucket: ossConfig.bucket ?? '',
|
||||
region: ossConfig.region ?? '',
|
||||
accessKeyId: ossConfig.accessKeyId ?? '',
|
||||
accessKeySecret: ossConfig.accessKeySecret ?? '',
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
if (!res || (res as { success?: boolean }).success === false) {
|
||||
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
|
||||
@@ -331,7 +327,7 @@ export function SettingsPage() {
|
||||
value="api-docs"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
|
||||
>
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
API 文档
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -606,10 +602,10 @@ export function SettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Cloud className="w-5 h-5 text-[#38bdac]" />
|
||||
阿里云 OSS 配置
|
||||
OSS 配置(阿里云对象存储)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置阿里云对象存储,用于图片和视频的云端存储(配置后将替代本地存储)
|
||||
endpoint、bucket、accessKey 等,用于图片/文件上传
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -625,6 +621,17 @@ export function SettingsPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">Bucket</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="bucket 名称"
|
||||
value={ossConfig.bucket ?? ''}
|
||||
onChange={(e) =>
|
||||
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">Region</Label>
|
||||
<Input
|
||||
@@ -640,7 +647,7 @@ export function SettingsPage() {
|
||||
<Label className="text-gray-300">AccessKey ID</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="LTAI5t..."
|
||||
placeholder="AccessKey ID"
|
||||
value={ossConfig.accessKeyId ?? ''}
|
||||
onChange={(e) =>
|
||||
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
|
||||
@@ -652,31 +659,13 @@ export function SettingsPage() {
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="********"
|
||||
placeholder="AccessKey Secret"
|
||||
value={ossConfig.accessKeySecret ?? ''}
|
||||
onChange={(e) =>
|
||||
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label className="text-gray-300">Bucket 名称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="my-soul-bucket"
|
||||
value={ossConfig.bucket ?? ''}
|
||||
onChange={(e) =>
|
||||
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'bg-green-500/10 border border-green-500/30' : 'bg-amber-500/10 border border-amber-500/30'}`}>
|
||||
<p className={`text-xs ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'text-green-300' : 'text-amber-300'}`}>
|
||||
{ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId
|
||||
? `✅ OSS 已配置(${ossConfig.bucket}.${ossConfig.endpoint}),上传将自动使用云端存储`
|
||||
: '⚠ 未配置 OSS,当前上传存储在本地服务器。填写以上信息并保存后自动启用云端存储'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -770,7 +759,7 @@ export function SettingsPage() {
|
||||
搜索功能
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 ml-6">控制首页搜索栏的显示</p>
|
||||
<p className="text-xs text-gray-400 ml-6">控制首页、目录页搜索栏的显示</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="search-enabled"
|
||||
|
||||
@@ -245,9 +245,17 @@ export function UsersPage() {
|
||||
if (!confirm('确定要删除这个用户吗?')) return
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
|
||||
if (data?.success) loadUsers()
|
||||
else toast.error('删除失败: ' + (data?.error || ''))
|
||||
} catch { toast.error('删除失败') }
|
||||
if (data?.success) {
|
||||
toast.success('已删除')
|
||||
loadUsers()
|
||||
} else {
|
||||
toast.error('删除失败: ' + (data?.error || '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error & { data?: { error?: string } }
|
||||
const msg = err?.data?.error || err?.message || '网络错误'
|
||||
toast.error('删除失败: ' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
|
||||
@@ -46,11 +46,13 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502
|
||||
// 预热 Redis 缓存,避免首请求冷启动 502
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // 等 DB 完全就绪
|
||||
handler.WarmAllChaptersCache()
|
||||
handler.WarmBookPartsCache()
|
||||
handler.WarmConfigCache()
|
||||
handler.WarmLatestChaptersCache()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -6,7 +6,7 @@ require (
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/unrolled/secure v1.17.0
|
||||
@@ -16,47 +16,56 @@ require (
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/gin-contrib/gzip v1.2.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -8,16 +8,24 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -27,12 +35,20 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -45,10 +61,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -65,6 +87,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -83,10 +107,16 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
@@ -108,6 +138,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -129,10 +161,16 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
@@ -141,8 +179,12 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
75
soul-api/internal/cache/cache.go
vendored
75
soul-api/internal/cache/cache.go
vendored
@@ -17,11 +17,36 @@ const defaultTimeout = 2 * time.Second
|
||||
// KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del
|
||||
const KeyBookParts = "soul:book:parts"
|
||||
|
||||
// KeyAllChapters 全书章节列表,default 与 excludeFixed 两种
|
||||
func KeyAllChapters(cacheKey string) string {
|
||||
if cacheKey == "excludeFixed" {
|
||||
return "soul:book:all-chapters:excludeFixed"
|
||||
}
|
||||
return "soul:book:all-chapters"
|
||||
}
|
||||
|
||||
// KeyChaptersByPart 篇章内章节,格式 soul:book:chapters-by-part:{partId}
|
||||
func KeyChaptersByPart(partId string) string {
|
||||
return "soul:book:chapters-by-part:" + partId
|
||||
}
|
||||
|
||||
// KeyChaptersByPartPattern 用于批量删除 chapters-by-part 缓存
|
||||
const KeyChaptersByPartPattern = "soul:book:chapters-by-part:*"
|
||||
|
||||
// KeyBookLatestChapters 最新更新章节
|
||||
const KeyBookLatestChapters = "soul:book:latest-chapters"
|
||||
|
||||
// KeyFreeChapterIDs 免费章节 ID 列表(JSON 数组)
|
||||
const KeyFreeChapterIDs = "soul:config:free-chapters"
|
||||
|
||||
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
|
||||
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
|
||||
const KeyBookRecommended = "soul:book:recommended"
|
||||
const KeyBookStats = "soul:book:stats"
|
||||
const KeyConfigMiniprogram = "soul:config:miniprogram"
|
||||
const KeyConfigAuditMode = "soul:config:audit-mode"
|
||||
const KeyConfigCore = "soul:config:core"
|
||||
const KeyConfigReadExtras = "soul:config:read-extras"
|
||||
|
||||
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB)
|
||||
func Get(ctx context.Context, key string, dest interface{}) bool {
|
||||
@@ -81,12 +106,47 @@ func Del(ctx context.Context, key string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效
|
||||
func DelPattern(ctx context.Context, pattern string) {
|
||||
client := redis.Client()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, defaultTimeout*2)
|
||||
defer cancel()
|
||||
keys, err := client.Keys(ctx, pattern).Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
if err := client.Del(ctx, keys...).Err(); err != nil {
|
||||
log.Printf("cache.DelPattern %s: %v (非致命)", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
// BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长
|
||||
const BookPartsTTL = 10 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效
|
||||
// AllChaptersTTL 全书章节列表 TTL
|
||||
const AllChaptersTTL = 10 * time.Minute
|
||||
|
||||
// ChaptersByPartTTL 篇章内章节 TTL
|
||||
const ChaptersByPartTTL = 10 * time.Minute
|
||||
|
||||
// FreeChapterIDsTTL 免费章节配置 TTL
|
||||
const FreeChapterIDsTTL = 5 * time.Minute
|
||||
|
||||
// InvalidateBookParts 后台更新章节/内容时调用,使目录、章节列表等缓存失效
|
||||
func InvalidateBookParts() {
|
||||
Del(context.Background(), KeyBookParts)
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyBookParts)
|
||||
Del(ctx, KeyAllChapters("default"))
|
||||
Del(ctx, KeyAllChapters("excludeFixed"))
|
||||
Del(ctx, KeyBookLatestChapters)
|
||||
Del(ctx, KeyFreeChapterIDs)
|
||||
DelPattern(ctx, KeyChaptersByPartPattern)
|
||||
}
|
||||
|
||||
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
|
||||
@@ -99,9 +159,13 @@ func InvalidateBookCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 及拆分接口缓存失效
|
||||
func InvalidateConfig() {
|
||||
Del(context.Background(), KeyConfigMiniprogram)
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyConfigMiniprogram)
|
||||
Del(ctx, KeyConfigAuditMode)
|
||||
Del(ctx, KeyConfigCore)
|
||||
Del(ctx, KeyConfigReadExtras)
|
||||
}
|
||||
|
||||
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
||||
@@ -110,6 +174,9 @@ const BookRelatedTTL = 5 * time.Minute
|
||||
// ConfigTTL 配置接口 TTL
|
||||
const ConfigTTL = 10 * time.Minute
|
||||
|
||||
// AuditModeTTL 审核模式 TTL,管理端开关后需较快生效
|
||||
const AuditModeTTL = 1 * time.Minute
|
||||
|
||||
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
|
||||
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -54,11 +56,17 @@ func AdminDashboardStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders?limit=10
|
||||
func AdminDashboardRecentOrders(c *gin.Context) {
|
||||
db := database.DB()
|
||||
limit := 5
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 20 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(10).Find(&recentOrders)
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(limit).Find(&recentOrders)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
|
||||
}
|
||||
|
||||
@@ -180,6 +188,101 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all
|
||||
// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count
|
||||
func AdminTrackStats(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "week")
|
||||
if period != "today" && period != "week" && period != "month" && period != "all" {
|
||||
period = "week"
|
||||
}
|
||||
now := time.Now()
|
||||
var start time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
case "week":
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
|
||||
case "month":
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
case "all":
|
||||
start = time.Time{}
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
q := db.Model(&model.UserTrack{})
|
||||
if !start.IsZero() {
|
||||
q = q.Where("created_at >= ?", start)
|
||||
}
|
||||
if err := q.Find(&tracks).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// byModule: module -> map[key] -> count, key = action + "|" + target
|
||||
type item struct {
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Module string `json:"module"`
|
||||
Page string `json:"page"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
byModule := make(map[string]map[string]*item)
|
||||
total := 0
|
||||
for _, t := range tracks {
|
||||
total++
|
||||
module := "other"
|
||||
page := ""
|
||||
if len(t.ExtraData) > 0 {
|
||||
var extra map[string]interface{}
|
||||
if err := json.Unmarshal(t.ExtraData, &extra); err == nil {
|
||||
if m, ok := extra["module"].(string); ok && m != "" {
|
||||
module = m
|
||||
}
|
||||
if p, ok := extra["page"].(string); ok {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
}
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
key := t.Action + "|" + target
|
||||
if byModule[module] == nil {
|
||||
byModule[module] = make(map[string]*item)
|
||||
}
|
||||
if byModule[module][key] == nil {
|
||||
byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0}
|
||||
}
|
||||
byModule[module][key].Count++
|
||||
}
|
||||
// 转为前端期望格式:byModule[module] = [{action,target,module,page,count},...]
|
||||
out := make(map[string][]gin.H)
|
||||
for mod, m := range byModule {
|
||||
list := make([]gin.H, 0, len(m))
|
||||
for _, v := range m {
|
||||
list = append(list, gin.H{
|
||||
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
|
||||
})
|
||||
}
|
||||
out[mod] = list
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out})
|
||||
}
|
||||
|
||||
// AdminBalanceSummary GET /api/admin/balance/summary
|
||||
// 汇总代付金额(product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」
|
||||
func AdminBalanceSummary(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var totalGifted float64
|
||||
db.Model(&model.Order{}).Where("product_type IN ? AND status IN ?", []string{"gift_pay", "gift_pay_batch"}, paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalGifted)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalGifted": totalGifted}})
|
||||
}
|
||||
|
||||
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
|
||||
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
|
||||
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error
|
||||
|
||||
@@ -94,7 +94,27 @@ var bookPartsCache struct {
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
// chaptersByPartCache 篇章内章节列表内存缓存,30 秒 TTL
|
||||
type chaptersByPartEntry struct {
|
||||
data []model.Chapter
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var chaptersByPartCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*chaptersByPartEntry
|
||||
}
|
||||
|
||||
const chaptersByPartCacheTTL = 30 * time.Second
|
||||
|
||||
// InvalidateChaptersByPartCache 后台更新章节时调用,使 chapters-by-part 内存缓存失效
|
||||
func InvalidateChaptersByPartCache() {
|
||||
chaptersByPartCache.mu.Lock()
|
||||
chaptersByPartCache.entries = nil
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存(Redis+内存),避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
@@ -112,6 +132,7 @@ func WarmAllChaptersCache() {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -202,15 +223,26 @@ func WarmBookPartsCache() {
|
||||
}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
//
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part + book/stats,id↔mid 从各接口响应积累。
|
||||
// 保留以兼容旧版/管理端,计划后续下线。
|
||||
//
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
cacheKey := "default"
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
cacheKey = "excludeFixed"
|
||||
}
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyAllChapters(cacheKey), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
data := allChaptersCache.data
|
||||
@@ -220,6 +252,7 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
if cacheKey == "excludeFixed" {
|
||||
@@ -227,7 +260,6 @@ func BookAllChapters(c *gin.Context) {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -243,6 +275,8 @@ func BookAllChapters(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL)
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
@@ -311,14 +345,33 @@ func BookParts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
||||
// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效
|
||||
func BookChaptersByPart(c *gin.Context) {
|
||||
partId := c.Query("partId")
|
||||
if partId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 1. 优先 Redis
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyChaptersByPart(partId), &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
// 2. 内存缓存
|
||||
chaptersByPartCache.mu.RLock()
|
||||
if chaptersByPartCache.entries != nil {
|
||||
if e, ok := chaptersByPartCache.entries[partId]; ok && time.Now().Before(e.expires) {
|
||||
list := e.data
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
}
|
||||
chaptersByPartCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询
|
||||
db := database.DB()
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Where("part_id = ?", partId).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
@@ -336,9 +389,42 @@ func BookChaptersByPart(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
|
||||
// 回填 Redis + 内存
|
||||
cache.Set(context.Background(), cache.KeyChaptersByPart(partId), list, cache.ChaptersByPartTTL)
|
||||
chaptersByPartCache.mu.Lock()
|
||||
if chaptersByPartCache.entries == nil {
|
||||
chaptersByPartCache.entries = make(map[string]*chaptersByPartEntry)
|
||||
}
|
||||
chaptersByPartCache.entries[partId] = &chaptersByPartEntry{data: list, expires: time.Now().Add(chaptersByPartCacheTTL)}
|
||||
chaptersByPartCache.mu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// getOrderedChapterList 获取按 sort_order+id 排序的章节列表(复用 all-chapters 缓存)
|
||||
func getOrderedChapterList() []model.Chapter {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyAllChapters("default"), &list) && len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == "default" && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
list = allChaptersCache.data
|
||||
allChaptersCache.mu.RUnlock()
|
||||
return list
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
db := database.DB()
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&list).Error; err != nil || len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
return list
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
@@ -357,8 +443,16 @@ func BookChapterByMID(c *gin.Context) {
|
||||
}
|
||||
|
||||
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
||||
// Redis 缓存 5min,后台更新时失效
|
||||
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
var ids map[string]bool
|
||||
if cache.Get(context.Background(), cache.KeyFreeChapterIDs, &ids) {
|
||||
if ids == nil {
|
||||
return make(map[string]bool)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
ids = make(map[string]bool)
|
||||
for _, key := range []string{"free_chapters", "chapter_config"} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
|
||||
@@ -388,6 +482,7 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -550,6 +645,38 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
"isFree": isFree,
|
||||
}
|
||||
// 文章详情内直接输出上一篇/下一篇,省去单独请求
|
||||
if list := getOrderedChapterList(); len(list) > 0 {
|
||||
idx := -1
|
||||
for i, item := range list {
|
||||
if item.ID == ch.ID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 {
|
||||
toItem := func(c *model.Chapter) gin.H {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
t := c.SectionTitle
|
||||
if t == "" {
|
||||
t = c.ChapterTitle
|
||||
}
|
||||
return gin.H{"id": c.ID, "mid": c.MID, "title": t}
|
||||
}
|
||||
if idx > 0 {
|
||||
out["prev"] = toItem(&list[idx-1])
|
||||
} else {
|
||||
out["prev"] = nil
|
||||
}
|
||||
if idx < len(list)-1 {
|
||||
out["next"] = toItem(&list[idx+1])
|
||||
} else {
|
||||
out["next"] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if isFreeFromConfig {
|
||||
out["price"] = float64(0)
|
||||
} else if ch.Price != nil {
|
||||
@@ -773,13 +900,18 @@ func BookRecommended(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
||||
// Redis 缓存 5min,首页「最新更新」主接口
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var list []model.Chapter
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
@@ -799,9 +931,42 @@ func BookLatestChapters(c *gin.Context) {
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// WarmLatestChaptersCache 启动时预热最新章节 Redis 缓存(首页主接口)
|
||||
func WarmLatestChaptersCache() {
|
||||
var list []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
|
||||
}
|
||||
|
||||
func escapeLikeBook(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
|
||||
@@ -17,14 +17,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
|
||||
func buildMiniprogramConfig() gin.H {
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
@@ -32,17 +26,19 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
apiDomain = cfg.BaseURL
|
||||
}
|
||||
defaultMp := gin.H{
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"buyerDiscount": 5,
|
||||
"referralBindDays": 30,
|
||||
"minWithdraw": 10,
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"buyerDiscount": 5,
|
||||
"referralBindDays": 30,
|
||||
"minWithdraw": 10,
|
||||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||||
"mchId": "1318592501",
|
||||
"mchId": "1318592501",
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"success": true,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
@@ -134,10 +130,149 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["linkedMiniprograms"]; !has {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
// 明确归一化 auditMode:仅当 DB 显式为 true 时返回 true,否则一律 false(避免历史脏数据/类型异常导致误判)
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
mp["auditMode"] = true
|
||||
} else {
|
||||
mp["auditMode"] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
//
|
||||
// Deprecated: 计划迁移至 /config/core + /config/audit-mode + /config/read-extras,保留以兼容线上小程序
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
out := buildMiniprogramConfig()
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
|
||||
func GetAuditMode(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
auditMode := false
|
||||
if mp, ok := full["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
}
|
||||
out := gin.H{"auditMode": auditMode}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetCoreConfig GET /api/miniprogram/config/core 核心配置(prices、features、userDiscount、mpConfig),首屏/Tab 用
|
||||
func GetCoreConfig(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigCore, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"prices": full["prices"],
|
||||
"features": full["features"],
|
||||
"userDiscount": full["userDiscount"],
|
||||
"mpConfig": full["mpConfig"],
|
||||
}
|
||||
if out["prices"] == nil {
|
||||
out["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
}
|
||||
if out["features"] == nil {
|
||||
out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
}
|
||||
if out["userDiscount"] == nil {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
if out["mpConfig"] == nil {
|
||||
out["mpConfig"] = gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigCore, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetReadExtras GET /api/miniprogram/config/read-extras 阅读页扩展(linkTags、linkedMiniprograms),懒加载
|
||||
func GetReadExtras(c *gin.Context) {
|
||||
var cached gin.H
|
||||
if cache.Get(context.Background(), cache.KeyConfigReadExtras, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
full := buildMiniprogramConfig()
|
||||
out := gin.H{
|
||||
"linkTags": full["linkTags"],
|
||||
"linkedMiniprograms": full["linkedMiniprograms"],
|
||||
}
|
||||
if out["linkTags"] == nil {
|
||||
out["linkTags"] = []gin.H{}
|
||||
}
|
||||
if out["linkedMiniprograms"] == nil {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigReadExtras, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// WarmConfigCache 启动时预热 config 及拆分接口缓存,避免首请求冷启动
|
||||
func WarmConfigCache() {
|
||||
out := buildMiniprogramConfig()
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
// 拆分接口预热
|
||||
auditMode := false
|
||||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||||
if v, ok := mp["auditMode"].(bool); ok && v {
|
||||
auditMode = true
|
||||
}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
|
||||
core := gin.H{
|
||||
"success": true,
|
||||
"prices": out["prices"],
|
||||
"features": out["features"],
|
||||
"userDiscount": out["userDiscount"],
|
||||
"mpConfig": out["mpConfig"],
|
||||
}
|
||||
if core["prices"] == nil {
|
||||
core["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
}
|
||||
if core["features"] == nil {
|
||||
core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
}
|
||||
if core["userDiscount"] == nil {
|
||||
core["userDiscount"] = float64(5)
|
||||
}
|
||||
if core["mpConfig"] == nil {
|
||||
core["mpConfig"] = gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigCore, core, cache.ConfigTTL)
|
||||
readExtras := gin.H{
|
||||
"linkTags": out["linkTags"],
|
||||
"linkedMiniprograms": out["linkedMiniprograms"],
|
||||
}
|
||||
if readExtras["linkTags"] == nil {
|
||||
readExtras["linkTags"] = []gin.H{}
|
||||
}
|
||||
if readExtras["linkedMiniprograms"] == nil {
|
||||
readExtras["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigReadExtras, readExtras, cache.ConfigTTL)
|
||||
}
|
||||
|
||||
// DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用)
|
||||
func DBConfigGet(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
@@ -174,15 +309,17 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
apiDomain = cfg.BaseURL
|
||||
}
|
||||
defaultMp := gin.H{
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"appId": "wxb8bbb2b10dec74aa",
|
||||
"apiDomain": apiDomain,
|
||||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||||
"mchId": "1318592501",
|
||||
"minWithdraw": float64(10),
|
||||
"mchId": "1318592501",
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
"mpConfig": defaultMp,
|
||||
"ossConfig": gin.H{},
|
||||
@@ -289,12 +426,12 @@ func AdminReferralSettingsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
defaultConfig := gin.H{
|
||||
"distributorShare": float64(90),
|
||||
"minWithdrawAmount": float64(10),
|
||||
"bindingDays": float64(30),
|
||||
"userDiscount": float64(5),
|
||||
"withdrawFee": float64(5),
|
||||
"enableAutoWithdraw": false,
|
||||
"vipOrderShareVip": float64(20),
|
||||
"minWithdrawAmount": float64(10),
|
||||
"bindingDays": float64(30),
|
||||
"userDiscount": float64(5),
|
||||
"withdrawFee": float64(5),
|
||||
"enableAutoWithdraw": false,
|
||||
"vipOrderShareVip": float64(20),
|
||||
"vipOrderShareNonVip": float64(10),
|
||||
}
|
||||
var row model.SystemConfig
|
||||
@@ -337,11 +474,11 @@ func AdminReferralSettingsPost(c *gin.Context) {
|
||||
val := gin.H{
|
||||
"distributorShare": body.DistributorShare,
|
||||
"minWithdrawAmount": body.MinWithdrawAmount,
|
||||
"bindingDays": body.BindingDays,
|
||||
"userDiscount": body.UserDiscount,
|
||||
"withdrawFee": body.WithdrawFee,
|
||||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||||
"vipOrderShareVip": vipOrderShareVip,
|
||||
"bindingDays": body.BindingDays,
|
||||
"userDiscount": body.UserDiscount,
|
||||
"withdrawFee": body.WithdrawFee,
|
||||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||||
"vipOrderShareVip": vipOrderShareVip,
|
||||
"vipOrderShareNonVip": vipOrderShareNonVip,
|
||||
}
|
||||
valBytes, err := json.Marshal(val)
|
||||
@@ -456,12 +593,12 @@ func AdminAuthorSettingsPost(c *gin.Context) {
|
||||
err := db.First(&row).Error
|
||||
if err != nil {
|
||||
row = model.AuthorConfig{
|
||||
Name: name,
|
||||
Avatar: avatar,
|
||||
AvatarImg: str("avatarImg"),
|
||||
Title: str("title"),
|
||||
Bio: str("bio"),
|
||||
Stats: string(statsBytes),
|
||||
Name: name,
|
||||
Avatar: avatar,
|
||||
AvatarImg: str("avatarImg"),
|
||||
Title: str("title"),
|
||||
Bio: str("bio"),
|
||||
Stats: string(statsBytes),
|
||||
Highlights: string(highlightsBytes),
|
||||
}
|
||||
err = db.Create(&row).Error
|
||||
@@ -547,7 +684,7 @@ func DBUsersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -720,21 +857,21 @@ func DBUsersList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrFloat64(f float64) *float64 { v := f; return &v }
|
||||
func ptrInt(n int) *int { return &n }
|
||||
func ptrInt(n int) *int { return &n }
|
||||
|
||||
// DBUsersAction POST /api/db/users(创建)、PUT /api/db/users(更新)
|
||||
func DBUsersAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
if c.Request.Method == http.MethodPost {
|
||||
var body struct {
|
||||
OpenID *string `json:"openId"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
OpenID *string `json:"openId"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -764,25 +901,25 @@ func DBUsersAction(c *gin.Context) {
|
||||
}
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio;tags 存 ckb_tags)
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
@@ -902,7 +1039,7 @@ func randomSuffix() string {
|
||||
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
|
||||
}
|
||||
|
||||
// DBUsersDelete DELETE /api/db/users
|
||||
// DBUsersDelete DELETE /api/db/users(软删除:仅设置 deleted_at,用户再次登录会新建账号)
|
||||
func DBUsersDelete(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
@@ -910,29 +1047,16 @@ func DBUsersDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
cleanupTables := []struct{ table, col string }{
|
||||
{"match_records", "user_id"},
|
||||
{"reading_progress", "user_id"},
|
||||
{"user_tracks", "user_id"},
|
||||
{"referral_bindings", "referrer_id"},
|
||||
{"referral_bindings", "referee_id"},
|
||||
{"referral_visits", "visitor_id"},
|
||||
{"ckb_submit_records", "user_id"},
|
||||
{"ckb_lead_records", "user_id"},
|
||||
{"user_addresses", "user_id"},
|
||||
{"user_balances", "user_id"},
|
||||
{"balance_transactions", "user_id"},
|
||||
{"withdrawals", "user_id"},
|
||||
{"orders", "user_id"},
|
||||
}
|
||||
for _, t := range cleanupTables {
|
||||
db.Exec("DELETE FROM "+t.table+" WHERE "+t.col+" = ?", id)
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
result := db.Where("id = ?", id).Delete(&model.User{})
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在或已被删除"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户已删除(假删除),该用户再次登录将创建新账号"})
|
||||
}
|
||||
|
||||
// DBUsersReferrals GET /api/db/users/referrals(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算)
|
||||
@@ -990,9 +1114,9 @@ func DBUsersReferrals(c *gin.Context) {
|
||||
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free,供前端徽章展示
|
||||
referrals = append(referrals, gin.H{
|
||||
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
|
||||
"hasFullBook": hasFullBook || status == "converted",
|
||||
"hasFullBook": hasFullBook || status == "converted",
|
||||
"purchasedSections": getBindingPurchaseCount(b),
|
||||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||||
"status": displayStatus,
|
||||
})
|
||||
}
|
||||
@@ -1099,7 +1223,7 @@ func DBDistribution(c *gin.Context) {
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
query = query.Where("status = ?", statusFilter)
|
||||
}
|
||||
if err := query.Offset((page-1)*pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
|
||||
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ func DBBookAction(c *gin.Context) {
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
@@ -501,6 +502,7 @@ func DBBookAction(c *gin.Context) {
|
||||
imported++
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
@@ -566,6 +568,7 @@ func DBBookAction(c *gin.Context) {
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
@@ -582,6 +585,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
@@ -607,6 +611,7 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
|
||||
return
|
||||
@@ -716,6 +721,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
cache.InvalidateChapterContent(ch.MID)
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
@@ -731,6 +737,7 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
cache.InvalidateChapterContentByID(body.ID)
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
@@ -778,6 +785,7 @@ func DBBookDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,9 +15,24 @@ import (
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const giftPayExpireHours = 24
|
||||
const wechatAttachMaxBytes = 128
|
||||
|
||||
// truncateStr 截断字符串至最多 n 字节(UTF-8 安全)
|
||||
func truncateStr(s string, n int) string {
|
||||
b := []byte(s)
|
||||
if len(b) <= n {
|
||||
return s
|
||||
}
|
||||
b = b[:n]
|
||||
for len(b) > 0 && b[len(b)-1] >= 0x80 {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// giftPayPreviewContent 取内容前 20%,用于代付页营销展示
|
||||
func giftPayPreviewContent(content string) string {
|
||||
@@ -38,17 +54,23 @@ func giftPayPreviewContent(content string) string {
|
||||
return string(runes[:limit]) + "……"
|
||||
}
|
||||
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求(改造后:发起人支付,好友领取)
|
||||
func GiftPayCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
quantity := req.Quantity
|
||||
if quantity < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发放份数须为正整数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 校验发起人
|
||||
@@ -70,11 +92,15 @@ func GiftPayCreate(c *gin.Context) {
|
||||
productID = "fullbook"
|
||||
}
|
||||
}
|
||||
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
amount := unitPrice * float64(quantity)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
// 发起人若有推荐人绑定,享受好友优惠
|
||||
var referrerID *string
|
||||
var binding struct {
|
||||
@@ -91,7 +117,11 @@ func GiftPayCreate(c *gin.Context) {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
amount = amount * (1 - userDiscount/100)
|
||||
unitPrice = unitPrice * (1 - userDiscount/100)
|
||||
if unitPrice < 0.01 {
|
||||
unitPrice = 0.01
|
||||
}
|
||||
amount = unitPrice * float64(quantity)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
@@ -101,28 +131,7 @@ func GiftPayCreate(c *gin.Context) {
|
||||
}
|
||||
_ = referrerID // 分佣在 PayNotify 时按发起人计算
|
||||
|
||||
// 校验发起人是否已拥有
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status IN ?",
|
||||
req.UserID, "section", productID, []string{"paid", "completed"}).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有该章节"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ProductType == "fullbook" || req.ProductType == "vip" {
|
||||
var u model.User
|
||||
db.Where("id = ?", req.UserID).Select("has_full_book", "is_vip", "vip_expire_date").First(&u)
|
||||
if u.HasFullBook != nil && *u.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有全书"})
|
||||
return
|
||||
}
|
||||
if req.ProductType == "vip" && u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已是有效VIP"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 改造后:发起人帮别人买,发起人自己可已拥有,不再校验
|
||||
|
||||
// 描述
|
||||
desc := ""
|
||||
@@ -155,95 +164,44 @@ func GiftPayCreate(c *gin.Context) {
|
||||
ProductType: req.ProductType,
|
||||
ProductID: productID,
|
||||
Amount: amount,
|
||||
Description: desc,
|
||||
Status: "pending",
|
||||
Description: desc,
|
||||
Status: "pending_pay",
|
||||
Quantity: quantity,
|
||||
RedeemedCount: 0,
|
||||
ExpireAt: expireAt,
|
||||
}
|
||||
if err := db.Create(&gpr).Error; err != nil {
|
||||
fmt.Printf("[GiftPayCreate] 创建失败: %v\n", err)
|
||||
// 若报 unknown column 'quantity' 等,需执行 soul-api/scripts/add-gift-pay-quantity.sql
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
sectionTitle := desc
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
}
|
||||
path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"sectionTitle": sectionTitle,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx 代付详情(代付人用)
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发起人昵称(脱敏)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname").First(&initiator).Error; err == nil && initiator.Nickname != nil {
|
||||
n := *initiator.Nickname
|
||||
if len(n) > 2 {
|
||||
n = string([]rune(n)[0]) + "**"
|
||||
}
|
||||
nickname = n
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览,吸引代付人
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"amount": gpr.Amount,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayPay POST /api/miniprogram/gift-pay/pay 代付人发起支付
|
||||
func GiftPayPay(c *gin.Context) {
|
||||
// GiftPayInitiatorPay POST /api/miniprogram/gift-pay/initiator-pay 发起人支付(改造后:我帮别人付款)
|
||||
func GiftPayInitiatorPay(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
UserID string `json:"userId"` // 代付人ID,用于校验不能自己付
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
@@ -252,8 +210,8 @@ func GiftPayPay(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已处理"})
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending_pay").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已支付"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
@@ -261,55 +219,54 @@ func GiftPayPay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 不能自己给自己代付
|
||||
if req.UserID != "" && req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
if req.UserID != gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅发起人可支付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取代付人信息
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&payer).Error; err != nil {
|
||||
var initiator model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&initiator).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if payer.ID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
if initiator.ID != gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "登录用户与发起人不一致"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建订单(归属发起人,记录代付信息)
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
productType := "gift_pay_batch"
|
||||
productID := gpr.ProductID
|
||||
desc := gpr.Description
|
||||
desc := fmt.Sprintf("代付分享 - %s × %d 份", gpr.Description, gpr.Quantity)
|
||||
gprID := gpr.ID
|
||||
payerID := payer.ID
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: gpr.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &payerID,
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &gpr.InitiatorUserID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 唤起微信支付,attach 中 userId=发起人,giftPayRequestSn=请求号
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s","giftPayRequestSn":"%s"}`,
|
||||
gpr.ProductType, gpr.ProductID, gpr.InitiatorUserID, gpr.RequestSN)
|
||||
totalFee := int(gpr.Amount * 100)
|
||||
// 微信 attach 最大 128 字节;发起人付订单已存在,PayNotify 从 order 取 giftPayRequestSn
|
||||
attach := `{"ip":1}`
|
||||
totalFee := int(math.Round(gpr.Amount * 100)) // 与正常章节支付一致,避免浮点精度导致分额错误
|
||||
if totalFee < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "金额异常,无法发起支付"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
|
||||
if err != nil {
|
||||
@@ -322,9 +279,6 @@ func GiftPayPay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 预占:更新请求状态为 paying(可选,防并发)
|
||||
// 简化:不预占,PayNotify 时再更新
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
@@ -335,6 +289,277 @@ func GiftPayPay(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx&userId= 或 ?sectionId=xxx&userId= 预览态
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
sectionId := strings.TrimSpace(c.Query("sectionId"))
|
||||
callerUserID := strings.TrimSpace(c.Query("userId"))
|
||||
db := database.DB()
|
||||
|
||||
// 预览态:无 requestSn 有 sectionId,返回文章信息供创建代付
|
||||
if requestSn == "" && sectionId != "" {
|
||||
if callerUserID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
unitPrice, priceErr := getStandardPrice(db, "section", sectionId)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
// 发起人若有推荐人,享受折扣(与 create 一致)
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT referrer_id FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC LIMIT 1
|
||||
`, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
unitPrice = unitPrice * (1 - userDiscount/100)
|
||||
if unitPrice < 0.01 {
|
||||
unitPrice = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ch model.Chapter
|
||||
sectionTitle := ""
|
||||
productMid := 0
|
||||
if err := db.Select("section_title", "mid").Where("id = ?", sectionId).First(&ch).Error; err == nil {
|
||||
sectionTitle = ch.SectionTitle
|
||||
productMid = ch.MID
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"mode": "create",
|
||||
"sectionId": sectionId,
|
||||
"sectionTitle": sectionTitle,
|
||||
"productMid": productMid,
|
||||
"unitPrice": unitPrice,
|
||||
"isInitiator": true,
|
||||
"action": "create",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
isInitiator := callerUserID != "" && callerUserID == gpr.InitiatorUserID
|
||||
|
||||
// 发起人昵称与头像(完整展示)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
initiatorAvatar := ""
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname", "avatar").First(&initiator).Error; err == nil {
|
||||
if initiator.Nickname != nil && *initiator.Nickname != "" {
|
||||
nickname = *initiator.Nickname
|
||||
}
|
||||
if initiator.Avatar != nil && *initiator.Avatar != "" {
|
||||
initiatorAvatar = *initiator.Avatar
|
||||
}
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
productMid := 0
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content", "mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
productMid = ch.MID
|
||||
}
|
||||
}
|
||||
|
||||
// 领取记录(发起人查看)
|
||||
var redeemList []gin.H
|
||||
if isInitiator {
|
||||
var orders []model.Order
|
||||
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
gpr.ID, "section", "paid").Order("created_at ASC").Find(&orders)
|
||||
for _, o := range orders {
|
||||
if o.UserID == "" {
|
||||
continue
|
||||
}
|
||||
var u model.User
|
||||
nickname := "用户"
|
||||
avatar := ""
|
||||
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
|
||||
if u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")})
|
||||
}
|
||||
}
|
||||
|
||||
// action: pay=发起人待支付 | share=发起人已支付可分享 | redeem=好友可领取 | wait=好友待发起人支付
|
||||
action := ""
|
||||
if isInitiator {
|
||||
if gpr.Status == "pending_pay" {
|
||||
action = "pay"
|
||||
} else if gpr.Status == "paid" {
|
||||
action = "share"
|
||||
} else if gpr.Status == "pending" {
|
||||
action = "share" // 旧版:待好友付
|
||||
}
|
||||
} else {
|
||||
if gpr.Status == "pending_pay" || gpr.Status == "pending" {
|
||||
action = "wait"
|
||||
} else if gpr.Status == "paid" {
|
||||
// 好友已领取过:返回 alreadyRedeemed,供前端直接跳转 read
|
||||
var existCnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
callerUserID, gpr.ID, "section", "paid",
|
||||
).Count(&existCnt)
|
||||
if existCnt > 0 {
|
||||
action = "alreadyRedeemed"
|
||||
} else {
|
||||
action = "redeem"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"productMid": productMid,
|
||||
"amount": gpr.Amount,
|
||||
"quantity": gpr.Quantity,
|
||||
"redeemedCount": gpr.RedeemedCount,
|
||||
"redeemList": redeemList,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorAvatar": initiatorAvatar,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"isInitiator": isInitiator,
|
||||
"action": action,
|
||||
"status": gpr.Status,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GiftPayRedeem POST /api/miniprogram/gift-pay/redeem 好友领取(改造后:免费获得章节)
|
||||
func GiftPayRedeem(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "paid").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或未支付"})
|
||||
return
|
||||
}
|
||||
if req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发起人无需领取"})
|
||||
return
|
||||
}
|
||||
if gpr.RedeemedCount >= gpr.Quantity {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "已领完"})
|
||||
return
|
||||
}
|
||||
|
||||
// 同一用户同一 requestSn 只能领一次
|
||||
var existCnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
req.UserID, gpr.ID, "section", "paid",
|
||||
).Count(&existCnt)
|
||||
if existCnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已领取过"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建好友订单:productType=section, status=paid, paymentMethod=gift_pay
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "paid"
|
||||
pm := "gift_pay"
|
||||
productID := gpr.ProductID
|
||||
desc := fmt.Sprintf("代付领取 - %s", gpr.Description)
|
||||
gprID := gpr.ID
|
||||
amount := 0.0
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: "section",
|
||||
ProductID: &productID,
|
||||
Amount: amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &gpr.InitiatorUserID,
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.GiftPayRequest{}).Where("id = ?", gpr.ID).
|
||||
Update("redeemed_count", gorm.Expr("redeemed_count + 1")).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "领取失败"})
|
||||
return
|
||||
}
|
||||
|
||||
_ = amount
|
||||
productMid := 0
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
productMid = ch.MID
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"sectionId": gpr.ProductID,
|
||||
"sectionMid": productMid,
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
|
||||
func GiftPayCancel(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -356,7 +581,7 @@ func GiftPayCancel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
|
||||
return
|
||||
}
|
||||
if gpr.Status != "pending" {
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
@@ -365,7 +590,7 @@ func GiftPayCancel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
|
||||
}
|
||||
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的(含领取记录)
|
||||
func GiftPayMyRequests(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
@@ -375,45 +600,45 @@ func GiftPayMyRequests(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
db.Where("initiator_user_id = ? AND status != ?", userID, "cancelled").Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
// 领取记录:orders 表 gift_pay_request_id + product_type=section + payment_method=gift_pay
|
||||
var redeemList []gin.H
|
||||
var orders []model.Order
|
||||
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
|
||||
r.ID, "section", "paid").Order("created_at ASC").Find(&orders) // 好友领取订单
|
||||
for _, o := range orders {
|
||||
if o.UserID == "" {
|
||||
continue
|
||||
}
|
||||
var u model.User
|
||||
nickname := "用户"
|
||||
avatar := ""
|
||||
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
|
||||
if u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
redeemAt := o.CreatedAt.Format("2006-01-02 15:04")
|
||||
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": redeemAt})
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// GiftPayMyPayments GET /api/miniprogram/gift-pay/my-payments?userId= 我帮付的
|
||||
func GiftPayMyPayments(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("payer_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"quantity": r.Quantity,
|
||||
"redeemedCount": r.RedeemedCount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
"redeemList": redeemList,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
@@ -479,6 +704,8 @@ func AdminGiftPayRequestsList(c *gin.Context) {
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"quantity": r.Quantity,
|
||||
"redeemedCount": r.RedeemedCount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"payerUserId": r.PayerUserID,
|
||||
|
||||
@@ -69,8 +69,8 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 创建新用户
|
||||
userID := openID // 直接使用 openid 作为用户 ID
|
||||
// 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突)
|
||||
userID := "user_" + randomSuffix()
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
@@ -408,9 +408,17 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// userID:优先用客户端传入;为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
if userID == "" && req.OpenID != "" {
|
||||
var u model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
|
||||
userID = u.ID
|
||||
} else {
|
||||
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
@@ -538,13 +546,38 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
|
||||
PT string `json:"pt"`
|
||||
PID string `json:"pid"`
|
||||
UID string `json:"uid"`
|
||||
SN string `json:"sn"`
|
||||
IP int `json:"ip"`
|
||||
}
|
||||
if attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
if attach.ProductType == "" {
|
||||
if attach.PT == "gpb" {
|
||||
attach.ProductType = "gift_pay_batch"
|
||||
} else {
|
||||
attach.ProductType = attach.PT
|
||||
}
|
||||
}
|
||||
if attach.ProductID == "" {
|
||||
attach.ProductID = attach.PID
|
||||
}
|
||||
if attach.UserID == "" {
|
||||
attach.UserID = attach.UID
|
||||
}
|
||||
if attach.GiftPayRequestSn == "" {
|
||||
attach.GiftPayRequestSn = attach.SN
|
||||
}
|
||||
if attach.IP != 0 {
|
||||
attach.GiftPayInitiatorPay = true
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
@@ -612,13 +645,23 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 代付订单:更新 gift_pay_request、订单 payer_user_id
|
||||
// 权益归属与分佣:代付时归发起人(order.UserID),普通订单归 buyerUserID
|
||||
beneficiaryUserID := buyerUserID
|
||||
if attach.GiftPayRequestSn != "" && order.UserID != "" {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单,权益归属发起人: %s\n", beneficiaryUserID)
|
||||
// 权益归属与分佣:旧版好友付归发起人;新版发起人付不发放权益(好友领取时再发)
|
||||
giftPayRequestSn := attach.GiftPayRequestSn
|
||||
if giftPayRequestSn == "" && order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("id = ?", *order.GiftPayRequestID).Select("request_sn").First(&gpr).Error; err == nil {
|
||||
giftPayRequestSn = gpr.RequestSN
|
||||
}
|
||||
}
|
||||
if attach.GiftPayRequestSn != "" {
|
||||
beneficiaryUserID := buyerUserID
|
||||
if giftPayRequestSn != "" && order.UserID != "" && !attach.GiftPayInitiatorPay {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单(好友付),权益归属发起人: %s\n", beneficiaryUserID)
|
||||
}
|
||||
if attach.GiftPayInitiatorPay {
|
||||
fmt.Printf("[PayNotify] 代付订单(发起人付),不发放权益,好友领取时再发\n")
|
||||
}
|
||||
if giftPayRequestSn != "" {
|
||||
var payerUserID string
|
||||
if openID != "" {
|
||||
var payer model.User
|
||||
@@ -627,7 +670,7 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
db.Model(&order).Update("payer_user_id", payerUserID)
|
||||
}
|
||||
}
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", giftPayRequestSn).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"payer_user_id": payerUserID,
|
||||
|
||||
@@ -664,6 +664,11 @@ func MiniprogramTrackPost(c *gin.Context) {
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if body.ExtraData != nil {
|
||||
if b, err := json.Marshal(body.ExtraData); err == nil {
|
||||
t.ExtraData = b
|
||||
}
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -54,6 +54,8 @@ func WechatPhoneLogin(c *gin.Context) {
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
|
||||
userID := "user_" + randomSuffix()
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
@@ -67,7 +69,7 @@ func WechatPhoneLogin(c *gin.Context) {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
user = model.User{
|
||||
ID: openID,
|
||||
ID: userID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
|
||||
@@ -2,7 +2,8 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GiftPayRequest 代付请求表(美团式:发起人创建,好友支付,权益归发起人)
|
||||
// GiftPayRequest 代付请求表(改造后:发起人创建并支付,好友领取)
|
||||
// status: pending_pay(待发起人支付)| paid(已支付待领取)| cancelled | expired
|
||||
type GiftPayRequest struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
RequestSN string `gorm:"column:request_sn;uniqueIndex;size:32" json:"requestSn"`
|
||||
@@ -11,7 +12,9 @@ type GiftPayRequest struct {
|
||||
ProductID string `gorm:"column:product_id;size:50" json:"productId"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending / paid / cancelled / expired
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending_pay / paid / cancelled / expired
|
||||
Quantity int `gorm:"column:quantity;default:1" json:"quantity"`
|
||||
RedeemedCount int `gorm:"column:redeemed_count;default:0" json:"redeemedCount"`
|
||||
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
|
||||
OrderID *string `gorm:"column:order_id;size:50" json:"orderId,omitempty"`
|
||||
ExpireAt time.Time `gorm:"column:expire_at" json:"expireAt"`
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
// 软删除:管理端删除仅设置 deleted_at,用户再次登录会创建新账号
|
||||
type User struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
@@ -51,6 +56,9 @@ type User struct {
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
|
||||
// 软删除:管理端假删除,用户再次登录会新建账号
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
|
||||
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
|
||||
WalletBalance *float64 `gorm:"-" json:"walletBalance,omitempty"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"soul-api/internal/redis"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
_ = r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
|
||||
r.Use(middleware.Secure())
|
||||
r.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.CORSOrigins,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
@@ -93,6 +95,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
|
||||
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
|
||||
admin.GET("/balance/summary", handler.AdminBalanceSummary)
|
||||
admin.GET("/users", handler.AdminUsersList)
|
||||
admin.POST("/users", handler.AdminUsersAction)
|
||||
admin.PUT("/users", handler.AdminUsersAction)
|
||||
@@ -108,6 +111,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
api.POST("/auth/reset-password", handler.AuthResetPassword)
|
||||
|
||||
// ----- 书籍/章节(只读,写操作由 /api/db/book 管理端路由承担) -----
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part,保留以兼容 next-project/管理端
|
||||
api.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
api.GET("/book/parts", handler.BookParts)
|
||||
api.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
@@ -131,7 +135,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
|
||||
// ----- 配置 -----
|
||||
api.GET("/config", handler.GetConfig)
|
||||
// 小程序用:GET /api/db/config 返回 freeChapters、prices(不鉴权,先于 db 组匹配)
|
||||
// Deprecated: 小程序已迁移至 /miniprogram/config/core + audit-mode + read-extras,保留以兼容 next-project
|
||||
api.GET("/db/config", handler.GetPublicDBConfig)
|
||||
|
||||
// ----- 内容 -----
|
||||
@@ -274,6 +278,11 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
|
||||
miniprogram := api.Group("/miniprogram")
|
||||
{
|
||||
// config 拆分接口(优先匹配,路径更具体)
|
||||
miniprogram.GET("/config/audit-mode", handler.GetAuditMode)
|
||||
miniprogram.GET("/config/core", handler.GetCoreConfig)
|
||||
miniprogram.GET("/config/read-extras", handler.GetReadExtras)
|
||||
// Deprecated: 保留以兼容线上,计划迁移至上述拆分接口
|
||||
miniprogram.GET("/config", handler.GetPublicDBConfig)
|
||||
miniprogram.POST("/login", handler.MiniprogramLogin)
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
@@ -284,11 +293,12 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调,URL 需在商户平台配置
|
||||
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
|
||||
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part
|
||||
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
miniprogram.GET("/book/parts", handler.BookParts)
|
||||
miniprogram.GET("/book/chapters-by-part", handler.BookChaptersByPart)
|
||||
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
miniprogram.GET("/book/chapter/by-id/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/hot", handler.BookHot)
|
||||
miniprogram.GET("/book/recommended", handler.BookRecommended)
|
||||
miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters)
|
||||
@@ -349,13 +359,13 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/balance/refund", handler.BalanceRefundPost)
|
||||
miniprogram.POST("/balance/consume", handler.BalanceConsumePost)
|
||||
miniprogram.GET("/gift/link", handler.GiftLinkGet)
|
||||
// 代付(美团式:代付页面)
|
||||
// 代付(改造后:发起人支付,好友领取)
|
||||
miniprogram.POST("/gift-pay/create", handler.GiftPayCreate)
|
||||
miniprogram.POST("/gift-pay/initiator-pay", handler.GiftPayInitiatorPay)
|
||||
miniprogram.GET("/gift-pay/detail", handler.GiftPayDetail)
|
||||
miniprogram.POST("/gift-pay/pay", handler.GiftPayPay)
|
||||
miniprogram.POST("/gift-pay/redeem", handler.GiftPayRedeem)
|
||||
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
|
||||
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
|
||||
miniprogram.GET("/gift-pay/my-payments", handler.GiftPayMyPayments)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
@@ -396,8 +406,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
"database": dbStatus,
|
||||
"redis": redisStatus,
|
||||
})
|
||||
|
||||
@@ -299,7 +299,18 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int,
|
||||
return "", err
|
||||
}
|
||||
if res == nil || res.PrepayID == "" {
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空")
|
||||
// 微信 v3 错误时可能返回 code+message,或 err_code+err_code_des
|
||||
detail := "res=nil"
|
||||
if res != nil {
|
||||
if res.Code != "" || res.Message != "" {
|
||||
detail = fmt.Sprintf("code=%s message=%s", res.Code, res.Message)
|
||||
} else if res.ErrCode != "" || res.ErrCodeDes != "" {
|
||||
detail = fmt.Sprintf("err_code=%s err_code_des=%s", res.ErrCode, res.ErrCodeDes)
|
||||
} else {
|
||||
detail = fmt.Sprintf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空 (%s)", detail)
|
||||
}
|
||||
return res.PrepayID, nil
|
||||
}
|
||||
|
||||
6
soul-api/scripts/add-gift-pay-quantity.sql
Normal file
6
soul-api/scripts/add-gift-pay-quantity.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 代付逻辑改造:gift_pay_requests 增加 quantity、redeemed_count
|
||||
-- 执行:mysql -u user -p db < soul-api/scripts/add-gift-pay-quantity.sql
|
||||
-- 若列已存在会报 Duplicate column,可忽略
|
||||
|
||||
ALTER TABLE gift_pay_requests ADD COLUMN quantity INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE gift_pay_requests ADD COLUMN redeemed_count INT NOT NULL DEFAULT 0;
|
||||
5
soul-api/scripts/add-users-soft-delete.sql
Normal file
5
soul-api/scripts/add-users-soft-delete.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 用户软删除:管理端假删除,用户再次登录会新建账号
|
||||
-- 执行后,DELETE 操作改为 SET deleted_at,不再物理删除,避免外键约束
|
||||
|
||||
ALTER TABLE users ADD COLUMN deleted_at DATETIME(3) NULL DEFAULT NULL COMMENT '软删除时间' AFTER updated_at;
|
||||
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
|
||||
5
soul-api/scripts/fix-part-titles.sql
Normal file
5
soul-api/scripts/fix-part-titles.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 修复篇章标题:将 slug 形式的 part_title 更新为展示标题(数据来源:DB)
|
||||
-- 执行:node .cursor/scripts/db-exec/run.js -f soul-api/scripts/fix-part-titles.sql
|
||||
|
||||
-- part-2026-daily 的标题应为「2026每日派对干货」
|
||||
UPDATE chapters SET part_title = '2026每日派对干货' WHERE part_id = 'part-2026-daily' AND (part_title = 'part-2026-daily' OR part_title = '' OR part_title IS NULL);
|
||||
37459
soul-api/wechat/info.log
Normal file
37459
soul-api/wechat/info.log
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user