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:
Alex-larget
2026-03-18 17:55:34 +08:00
125 changed files with 46439 additions and 2916 deletions

View File

@@ -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`
- CPU1 颗 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菜单/布局新规范基线
### 决议(团队共享)

View File

@@ -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 + 分类取舍,不以日期判优劣 |

View File

@@ -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`

View File

@@ -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()
// 加载 mpConfigappId、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
},
// 加载 mpConfigappId、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
}

View File

@@ -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"

View 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 }))
}
},
},
})

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View 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>

View 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);
}

View File

@@ -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 映射:将业务 namelucide 风格)映射到 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: ''
})
}
}

View File

@@ -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>

View File

@@ -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%;

View File

@@ -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()
// 兼容两种返回格式

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '附录1Soul派对房精选对话' },
{ 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 + fixedSectionsbook/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: '附录1Soul派对房精选对话', 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' })
},

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 || ''
}
}
})

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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' })
}

View File

@@ -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>

View File

@@ -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;

View 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()
}
})

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View 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>

View 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);
}

View File

@@ -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()

View File

@@ -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>
<!-- 底部留白 -->

View File

@@ -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>

View File

@@ -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() {},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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. 获取上传后的完整URLOSS 返回完整 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创业派对 - 我的',

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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()
}))
})

View File

@@ -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>

View File

@@ -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+auditModegetConfig+ 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 构造章节接口路径优先 midby-mid否则用 idby-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(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/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' })

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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' })
},

View File

@@ -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">

View File

@@ -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}` })
},

View File

@@ -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>

View File

@@ -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; }
}
/* 空状态 */

View File

@@ -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">

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View 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
}
]
}
}
}

View 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"; }

View File

@@ -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
View 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>

View File

@@ -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` 或测试脚本,按场景组织用例。

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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
})

View File

@@ -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]" />

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">
endpointbucketaccessKey /
</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"

View File

@@ -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) => {

View File

@@ -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() {

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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 书籍相关接口 TTLhot/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) }

View File

@@ -227,6 +227,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -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

View File

@@ -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/statsid↔mid 从各接口响应积累。
// 保留以兼容旧版/管理端,计划后续下线。
//
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 带 30 秒内存缓存,管理端更新后最多 30 秒生
// 缓存优先级Redis10min> 内存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 请求)
// 缓存优先级Redis10min> 内存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, "%", "\\%")

View File

@@ -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" 时仅返回 VIPhasFullBook
vipFilter := c.Query("vip") // "true" 时仅返回 VIPhasFullBook
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_biotags 存 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
}

View File

@@ -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})
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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"`

View File

@@ -1,8 +1,13 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
// User 对应表 usersJSON 输出与现网接口 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"`

View File

@@ -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,
})

View File

@@ -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
}

View 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;

View 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);

View 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

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