重构小程序图标组件,替换传统 emoji 为 SVG 图标,提升视觉一致性和可维护性。更新多个页面以使用新图标组件,优化用户界面体验。同时,调整了数据加载逻辑,确保更高效的状态管理和用户交互。
This commit is contained in:
@@ -1,101 +1,5 @@
|
||||
# 团队共享 经验记录 - 2026-03-10
|
||||
|
||||
## 在 Windows 上一键启动 macOS 虚拟机(“龙虾”智能体经验)
|
||||
|
||||
### 场景 / 问题
|
||||
|
||||
- 成员希望在 **Windows 10/11** 上通过 **Docker** 一键安装 macOS,用于演示 / 测试。
|
||||
- 实际上:
|
||||
- **macOS 不能在 Docker 中运行**(Docker 只跑 Linux 容器,没有合法的 macOS 镜像)。
|
||||
- 唯一可行路径是:**WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM**。
|
||||
|
||||
### 关键决策
|
||||
|
||||
1. **明确技术与法律边界**
|
||||
- 不支持也不承诺在 Docker 中直接跑 macOS。
|
||||
- 统一采用「**WSL2 + QEMU/KVM + OneClick**」这个方案,仅用于演示 / 测试。
|
||||
|
||||
2. **固定目录与流程约定**
|
||||
- Windows 侧统一放在:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 侧路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 内部结构:
|
||||
- `OneClick-macOS-Simple-KVM/`
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
|
||||
3. **获取 OneClick 源码时优先走 zip,而不是 git clone**
|
||||
- 直接 `git clone` 很容易在国内网络环境下触发 `GnuTLS recv error (-110)` 等 TLS 超时。
|
||||
- 统一约定使用:
|
||||
- `https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master`
|
||||
- 然后在 WSL 中:
|
||||
- `curl + unzip` → 解压 → 重命名为 `OneClick-macOS-Simple-KVM`。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
- 在 Ubuntu-24.04 内安装:
|
||||
- `qemu-system qemu-utils python3 python3-pip cpu-checker`
|
||||
- 使用 `kvm-ok` 检查:
|
||||
- 期望输出:`/dev/kvm exists` + `KVM acceleration can be used`。
|
||||
- 若 `nested` 为 `N` 且需要嵌套虚拟化,使用 `.wslconfig` 打开 nested。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
- 通过 `python3 fetch-macOS-v2.py -s ventura` 下载官方 Recovery 镜像。
|
||||
- 将 `RecoveryImage.dmg` 重命名为 `BaseSystem.dmg`,再用 `qemu-img convert` 生成 `BaseSystem.img`。
|
||||
- 验收标准:
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
|
||||
6. **以 HEADLESS + VNC 方式启动 VM**
|
||||
- 使用 `sudo HEADLESS=1 ./basic.sh` 启动 QEMU。
|
||||
- 在 Windows 中确认 `127.0.0.1:5900` 端口监听。
|
||||
- 统一告知使用 VNC 客户端连接 `localhost:5900`,再在图形界面内完成 macOS 安装向导。**用户环境已采用 TightVNC**,与 RealVNC Viewer 等方式等效。
|
||||
|
||||
7. **WSL 卡死与多 wsl 进程清理策略**
|
||||
- 当 `wsl --shutdown` 卡住、或者有大量 `wsl` 进程残留时:
|
||||
- 使用 PowerShell `Get-Process wsl | Stop-Process -Force` 清理。
|
||||
- 再执行 `wsl --shutdown` + `wsl -l -v` 验证状态恢复。
|
||||
|
||||
### 对应 Skill / 智能体
|
||||
|
||||
- 新建 Skill:`.cursor/skills/lobster-macos-vm/SKILL.md`
|
||||
- 技能名:`lobster-macos-vm`
|
||||
- 智能体名(对外):**“龙虾”**
|
||||
- 职责:当用户在 Windows 上提出安装 / 维护 macOS 虚拟机的需求时,统一按该 Skill 流程执行:
|
||||
- 解释 Docker 不可用 → 切换到 WSL2 + QEMU 方案。
|
||||
- 固定目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → HEADLESS 启动 → 引导 VNC 安装。
|
||||
|
||||
- 计划脚本化:
|
||||
- 在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 中实现一键部署脚本,封装上述流程,供“龙虾”及人类成员复用。
|
||||
|
||||
### 用户环境补充
|
||||
|
||||
- **VNC 客户端**:当前环境使用 **TightVNC** 连接 `localhost:5900`,已写入龙虾 Skill,后续回复可一并推荐 TightVNC / RealVNC / TigerVNC 等。
|
||||
|
||||
### 安装完成与使用规范快照
|
||||
|
||||
- 当前虚拟机参数:
|
||||
- 内存:8G(`-m 8G`)
|
||||
- CPU:1 颗 CPU × 4 核 × 2 线程(共 8 线程)
|
||||
- 系统盘:`macOS.qcow2`,逻辑容量 64G,可后续通过 `qemu-img resize` 扩容。
|
||||
- 启动方式快照:
|
||||
- 优先使用 `C:\Users\{USERNAME}\Mycontent\macos-vm\一键启动-macOS虚拟机.bat`
|
||||
- bat 内部做两件事:
|
||||
1. 启动 WSL → 进入 `OneClick-macOS-Simple-KVM` → `sudo HEADLESS=1 ./basic.sh`
|
||||
2. 等待约 10 秒后自动启动 TightVNC Viewer 连接 `localhost:5900`
|
||||
- 关闭规则:不要关名为「macOS 虚拟机 - 勿关此窗口」的终端窗口,否则虚拟机会被强制关闭。
|
||||
- 安装阶段经验:
|
||||
- 安装过程中多次重启属正常,出现 `X86PlatformPlugin::systemWillShutdown!` / `IOPlatformHaltRestartAction -> AppleSMC` 说明在正常关机/重启。
|
||||
- OpenCore 菜单的选择顺序:
|
||||
- 安装阶段:多次选择 `macOS Installer` 直至不再出现安装向导。
|
||||
- 安装完成:只选系统盘(如 `Macintosh HD`),不要选 `mac - Data`。
|
||||
- 安装完成后,为避免反复从恢复盘启动,`basic.sh` 默认不再挂载 `BaseSystem.img`;需要重装时可通过 `INSTALL_MEDIA=1 HEADLESS=1 ./basic.sh` 临时挂载。
|
||||
|
||||
### 适用角色
|
||||
|
||||
- 后端 / 运维:需要在本地或服务器上快速拉起 macOS VM 做兼容性验证或演示。
|
||||
- 团队:对外说明 **“我们不支持 Docker macOS,统一用龙虾方案”**。
|
||||
|
||||
---
|
||||
|
||||
## 管理端迁移 Mycontent-temp:菜单/布局新规范基线
|
||||
|
||||
### 决议(团队共享)
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
|
||||
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」;match 弹窗已修正 |
|
||||
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx,个人中心类页面布局规范 |
|
||||
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的"龙虾"方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
|
||||
| 2026-03-10 | 小程序 | 最佳实践 | miniprogram-dev SKILL §my | my.js 阅读统计改为后端接口(loadDashboardStats),禁止用随机数时间/标题占位 |
|
||||
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | 聚合接口三处修复:recentChapters 去重、totalReadMinutes 最小1分钟、DB 错误返回 500 |
|
||||
| 2026-03-10 | 团队 | 方法论 | - | 新旧版代码对比:以功能完整性为基准,批量 diff + 分类取舍,不以日期判优劣 |
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
name: lobster-macos-vm
|
||||
description: Automates provisioning and troubleshooting of macOS virtual machines on Windows using WSL2, QEMU/KVM, and the OneClick-macOS-Simple-KVM project. Use when the user mentions 龙虾, macOS 虚拟机, 一键安装苹果系统 on Windows, or needs to re-deploy the Ventura/Sonoma VM.
|
||||
---
|
||||
|
||||
# 龙虾(lobster-macos-vm)
|
||||
|
||||
> 专门负责:在 **Windows 10/11** 上,通过 **WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM** 自动拉起一台 macOS 虚拟机(Ventura 为默认),并处理常见网络 / WSL / KVM 问题。
|
||||
|
||||
## 触发场景
|
||||
|
||||
- 用户提到:**“龙虾”**、**“苹果系统虚拟机”**、**“Windows 上跑 macOS”**、**“一键安装 macOS 虚拟机”**
|
||||
- 用户需要:在 Windows 上**演示 / 测试** macOS,而不是 Docker 容器
|
||||
- 用户遇到:WSL 安装失败、`0x80072ee2` 网络错误、`kvm-ok`、`nested` 配置问题、`git clone` TLS 超时、OneClick 项目下载问题
|
||||
|
||||
## 核心能力
|
||||
|
||||
1. **环境检测与前置说明**
|
||||
- 明确告诉用户:**macOS 不能在 Docker 里运行,必须用 WSL2 + 虚拟机**。
|
||||
- 检查:
|
||||
- `wsl -l -v` → 是否有 `Ubuntu-24.04`,是否为 Version 2
|
||||
- `docker --version` 仅作背景信息,不作为必需条件
|
||||
- 若缺失 WSL2:
|
||||
- 指导用户在**管理员 PowerShell**中执行:
|
||||
- `wsl --install`
|
||||
- 重启后执行 `wsl --install -d Ubuntu-24.04 --web-download`(必要时)
|
||||
|
||||
2. **固定部署目录约定**
|
||||
- 所有与 macOS VM 相关的文件,统一放到:
|
||||
- Windows 路径:`C:\Users\{USERNAME}\Mycontent\macos-vm`
|
||||
- WSL 路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
|
||||
- 该目录下结构:
|
||||
- `OneClick-macOS-Simple-KVM/`(从 GitHub 下载的项目)
|
||||
- `BaseSystem.dmg` / `BaseSystem.img`
|
||||
- `macOS.qcow2`
|
||||
- 可能还有 `OneClick.zip` 等临时文件
|
||||
|
||||
3. **获取 OneClick 源码(优先 zip,退而求其次 git)**
|
||||
|
||||
优先使用 **codeload.zip**,避免长时间 `git clone` TLS 超时:
|
||||
|
||||
- 在 `macos-vm/` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y curl unzip
|
||||
rm -rf OneClick-macOS-Simple-KVM OneClick.zip
|
||||
curl -L --retry 8 --retry-delay 2 --connect-timeout 20 --max-time 600 \
|
||||
-o OneClick.zip \
|
||||
https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master
|
||||
unzip -q OneClick.zip
|
||||
mv OneClick-macOS-Simple-KVM-master OneClick-macOS-Simple-KVM
|
||||
```
|
||||
|
||||
仅当用户网络环境允许且确有需要时,才尝试:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
```
|
||||
|
||||
出现 `GnuTLS recv error (-110)` 或 TLS 断开时,**不要重复 git clone**,改走 zip 方案。
|
||||
|
||||
4. **依赖安装与 KVM 检查**
|
||||
|
||||
在 `Ubuntu-24.04` 内执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
预期输出:
|
||||
|
||||
- `INFO: /dev/kvm exists`
|
||||
- `KVM acceleration can be used`
|
||||
|
||||
若 `nested` 为 `N` 但 `kvm-ok` 正常:
|
||||
|
||||
- 提示用户在 `C:\Users\{USERNAME}\.wslconfig` 中写入:
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
nestedVirtualization=true
|
||||
kernel=C:\\Users\\{USERNAME}\\bzImage
|
||||
debugConsole=true
|
||||
pageReporting=true
|
||||
kernelCommandLine=intel_iommu=on iommu=pt kvm.ignore_msrs=1 kvm-intel.nested=1 kvm-intel.ept=1 kvm-intel.emulate_invalid_guest_state=0 kvm-intel.enable_shadow_vmcs=1 kvm-intel.enable_apicv=1
|
||||
```
|
||||
|
||||
并执行 `wsl --shutdown` 之后重试。
|
||||
|
||||
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/{USERNAME}/Mycontent/macos-vm/OneClick-macOS-Simple-KVM
|
||||
chmod +x *.sh *.py
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
python3 fetch-macOS-v2.py -s ventura
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
ls -lah BaseSystem.* macOS.qcow2
|
||||
```
|
||||
|
||||
成功标志:
|
||||
|
||||
- `BaseSystem.dmg` ≈ 678 MB
|
||||
- `BaseSystem.img` ≈ 3.0 GB
|
||||
- `macOS.qcow2` 已存在(几十 KB 起步)
|
||||
|
||||
6. **启动虚拟机(headless + VNC: localhost:5900)**
|
||||
|
||||
在 `OneClick-macOS-Simple-KVM` 目录内执行:
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
常见日志:
|
||||
|
||||
- ALSA / audio 报错(没有声卡驱动)→ **可以忽略**
|
||||
- `BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00017"...` → 已经开始从虚拟硬盘启动
|
||||
|
||||
在 Windows 侧确认端口:
|
||||
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 5900 -State Listen
|
||||
```
|
||||
|
||||
若监听正常,提示用户:
|
||||
|
||||
- 安装 VNC 客户端并连接 `localhost:5900`,进入 macOS 安装向导(磁盘工具抹盘 + 安装系统)。
|
||||
- **常用 VNC 客户端**:RealVNC Viewer、**TightVNC**(用户环境已采用)、TigerVNC 等均可,连接地址均为 `localhost:5900`。
|
||||
|
||||
7. **WSL / 网络故障排查**
|
||||
|
||||
- 若 `wsl` 进程过多、`wsl --shutdown` 卡死:
|
||||
- 在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
Get-Process -Name wsl -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
wsl --shutdown
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
- 若 `wsl --install` 报 `0x80072ee2` 或无法访问 `raw.githubusercontent.com`:
|
||||
- 提醒用户这是 **网络 / DNS 问题**,可尝试:
|
||||
- 切换 DNS 到 `8.8.8.8`
|
||||
- 使用合规代理 / VPN
|
||||
- 使用 `--web-download` 方式安装发行版:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04 --web-download
|
||||
```
|
||||
|
||||
8. **Python 一键脚本(lobster_macos_vm.py)协同**
|
||||
|
||||
当仓库中存在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 时:
|
||||
|
||||
- 优先引导用户在 **PowerShell** 中执行:
|
||||
|
||||
```powershell
|
||||
python C:\Users\{USERNAME}\Mycontent\macos-vm\lobster_macos_vm.py
|
||||
```
|
||||
|
||||
- 该脚本应负责:
|
||||
- 检查 / 安装 WSL2 + Ubuntu-24.04(必要时提示用户重启)
|
||||
- 确保 `C:\Users\{USERNAME}\Mycontent\macos-vm` 目录存在
|
||||
- 在 WSL 内下载 OneClick 源码 zip、解压到固定目录
|
||||
- 安装 QEMU / Python 依赖并检查 `kvm-ok`
|
||||
- 下载 Ventura 恢复镜像并生成 `BaseSystem.img`
|
||||
- 启动 `sudo HEADLESS=1 ./basic.sh`
|
||||
- 输出清晰的步骤说明(包括如何用 VNC 连接)
|
||||
|
||||
## 使用示例
|
||||
|
||||
- 用户说:「**龙虾,帮我在这台 Windows 上一键装一个 macOS 虚拟机,用来演示**」
|
||||
- 按上述步骤依次执行:环境检测 → 创建 `macos-vm` 目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → 启动虚拟机,并提醒用户用 VNC 连 `localhost:5900` 完成图形安装。
|
||||
|
||||
- 用户说:「**龙虾,之前的 macOS 虚拟机挂了,重装一遍**」
|
||||
- 复用相同目录和镜像文件,必要时重新下载 `BaseSystem.dmg`,再启动 `HEADLESS=1 ./basic.sh`。
|
||||
|
||||
@@ -13,8 +13,8 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
|
||||
App({
|
||||
globalData: {
|
||||
// API 基础地址:开发时修改下面一行切换环境
|
||||
baseUrl: "https://soulapi.quwanzhi.com",
|
||||
// baseUrl: 'http://localhost:8080', // 开发
|
||||
// baseUrl: "https://soulapi.quwanzhi.com",
|
||||
baseUrl: 'http://localhost:8080', // 开发
|
||||
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
|
||||
// 小程序配置 - 真实AppID
|
||||
appId: DEFAULT_APP_ID,
|
||||
@@ -30,7 +30,7 @@ App({
|
||||
openId: null, // 微信openId,支付必需
|
||||
isLoggedIn: false,
|
||||
|
||||
// 书籍数据
|
||||
// 书籍数据(bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters)
|
||||
bookData: null,
|
||||
totalSections: 62,
|
||||
|
||||
@@ -76,7 +76,10 @@ App({
|
||||
// 审核模式:后端 /api/miniprogram/config 返回 auditMode=true 时隐藏所有支付相关UI
|
||||
auditMode: false,
|
||||
// 客服/微信:mp_config 返回 supportWechat
|
||||
supportWechat: ''
|
||||
supportWechat: '',
|
||||
// config 统一缓存(5min),减少重复请求
|
||||
configCache: null,
|
||||
configCacheExpires: 0
|
||||
},
|
||||
|
||||
onLaunch(options) {
|
||||
@@ -109,15 +112,15 @@ App({
|
||||
this.handleReferralCode(options)
|
||||
},
|
||||
|
||||
// 小程序显示时:处理分享参数、检测更新、刷新 mpConfig(从后台切回时)
|
||||
// 小程序显示时:处理分享参数、检测更新、刷新审核模式(从后台切回时)
|
||||
onShow(options) {
|
||||
this.handleReferralCode(options)
|
||||
this.checkUpdate()
|
||||
// 从后台切回时刷新审核模式等配置(节流 30 秒,避免频繁请求)
|
||||
// 从后台切回时仅刷新审核模式(轻量接口 /config/audit-mode),节流 30 秒
|
||||
const now = Date.now()
|
||||
if (!this.globalData.lastMpConfigCheck || now - this.globalData.lastMpConfigCheck > 30 * 1000) {
|
||||
this.globalData.lastMpConfigCheck = now
|
||||
this.loadMpConfig()
|
||||
this.getAuditMode()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -225,12 +228,6 @@ App({
|
||||
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
|
||||
},
|
||||
|
||||
// 根据业务 id 从 bookData 查 mid(用于跳转)
|
||||
getSectionMid(sectionId) {
|
||||
const list = this.globalData.bookData || []
|
||||
const ch = list.find(c => c.id === sectionId)
|
||||
return ch?.mid || 0
|
||||
},
|
||||
|
||||
// 获取当前用户的邀请码(用于分享带 ref,未登录返回空字符串)
|
||||
getMyReferralCode() {
|
||||
@@ -321,32 +318,112 @@ App({
|
||||
}
|
||||
},
|
||||
|
||||
// 加载书籍数据
|
||||
// 加载书籍元数据(totalSections),不再预加载 all-chapters
|
||||
async loadBookData() {
|
||||
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 = (chapters && chapters.length) ? chapters.length : 62
|
||||
wx.setStorageSync('bookData', chapters)
|
||||
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.error('加载书籍数据失败:', 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 (_) {}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 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 [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) {
|
||||
if (this.globalData.configCache) return this.globalData.configCache
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取阅读页扩展配置(linkTags、linkedMiniprograms),懒加载
|
||||
*/
|
||||
async getReadExtras() {
|
||||
if (Array.isArray(this.globalData.linkTagsConfig) && this.globalData.linkTagsConfig.length > 0) {
|
||||
return {
|
||||
linkTags: this.globalData.linkTagsConfig,
|
||||
linkedMiniprograms: this.globalData.linkedMiniprograms || []
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config/read-extras', silent: true, timeout: 5000 })
|
||||
if (res) {
|
||||
if (Array.isArray(res.linkTags)) this.globalData.linkTagsConfig = res.linkTags
|
||||
if (Array.isArray(res.linkedMiniprograms)) this.globalData.linkedMiniprograms = res.linkedMiniprograms
|
||||
return res
|
||||
}
|
||||
} catch (e) {}
|
||||
return { linkTags: [], linkedMiniprograms: [] }
|
||||
},
|
||||
|
||||
/**
|
||||
* 仅刷新审核模式(从后台切回时用,轻量)
|
||||
*/
|
||||
async getAuditMode() {
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config/audit-mode', silent: true, timeout: 3000 })
|
||||
if (res && typeof res.auditMode === 'boolean') {
|
||||
this.globalData.auditMode = res.auditMode
|
||||
if (this.globalData.configCache && this.globalData.configCache.mpConfig) {
|
||||
this.globalData.configCache.mpConfig.auditMode = res.auditMode
|
||||
}
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
pages.forEach(p => {
|
||||
if (p && p.data && 'auditMode' in p.data) {
|
||||
p.setData({ auditMode: res.auditMode })
|
||||
}
|
||||
})
|
||||
} catch (_) {}
|
||||
return res.auditMode
|
||||
}
|
||||
} catch (e) {}
|
||||
return this.globalData.auditMode
|
||||
},
|
||||
|
||||
// 加载 mpConfig(appId、mchId、withdrawSubscribeTmplId、auditMode、supportWechat 等),失败时保留 globalData 默认值
|
||||
async loadMpConfig() {
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
|
||||
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
|
||||
@@ -423,6 +500,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(用于静默请求如访问统计)
|
||||
@@ -437,6 +515,7 @@ App({
|
||||
} else {
|
||||
url = ''
|
||||
}
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const silent = !!options.silent
|
||||
const showError = (msg) => {
|
||||
if (!silent && msg) {
|
||||
@@ -444,7 +523,16 @@ App({
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 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,
|
||||
@@ -492,6 +580,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用于支付(加固错误处理,避免审核报“登录报错”)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"icon": "/components/icon/icon"
|
||||
},
|
||||
"pages": [
|
||||
"pages/chapters/chapters",
|
||||
"pages/index/index",
|
||||
|
||||
434
miniprogram/assets/iconfont.css
Normal file
434
miniprogram/assets/iconfont.css
Normal file
@@ -0,0 +1,434 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 5142223 */
|
||||
src: url('//at.alicdn.com/t/c/font_5142223_1sq6pv9vvbt.woff2?t=1773819902347') format('woff2'),
|
||||
url('//at.alicdn.com/t/c/font_5142223_1sq6pv9vvbt.woff?t=1773819902347') format('woff'),
|
||||
url('//at.alicdn.com/t/c/font_5142223_1sq6pv9vvbt.ttf?t=1773819902347') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: "iconfont" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-wallet: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-direction-left:before {
|
||||
content: "\e67f";
|
||||
}
|
||||
|
||||
.icon-download:before {
|
||||
content: "\e680";
|
||||
}
|
||||
|
||||
.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";
|
||||
}
|
||||
@@ -41,20 +41,55 @@ Component({
|
||||
},
|
||||
|
||||
methods: {
|
||||
// SVG 图标数据映射
|
||||
// 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': '<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>'
|
||||
'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>',
|
||||
'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] || ''
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
// 兼容两种返回格式
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<!-- 书籍信息 -->
|
||||
<view class="book-info-card" wx:if="{{bookInfo && !authorLoading}}">
|
||||
<text class="card-title">📚 {{bookInfo.title}}</text>
|
||||
<view class="card-title"><icon name="book-open" size="36" color="#00CED1" customClass="card-title-icon"></icon><text>{{bookInfo.title}}</text></view>
|
||||
<view class="book-stats">
|
||||
<view class="book-stat">
|
||||
<text class="book-stat-value">{{bookInfo.totalChapters}}</text>
|
||||
@@ -65,7 +65,7 @@
|
||||
<view class="contact-card" wx:if="{{!authorLoading}}">
|
||||
<text class="card-title">联系作者</text>
|
||||
<view class="contact-item">
|
||||
<text class="contact-icon">🎉</text>
|
||||
<icon name="sparkles" size="40" color="#00CED1" customClass="contact-icon"></icon>
|
||||
<view class="contact-info">
|
||||
<text class="contact-label">Soul派对房</text>
|
||||
<text class="contact-value">每天早上6-9点开播</text>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
.stat-value { font-size: 36rpx; font-weight: 700; color: #00CED1; display: block; }
|
||||
.stat-label { font-size: 22rpx; color: rgba(255,255,255,0.5); }
|
||||
.contact-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: block; margin-bottom: 24rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 12rpx; margin-bottom: 24rpx; }
|
||||
.card-title .card-title-icon { flex-shrink: 0; }
|
||||
.contact-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 16rpx; }
|
||||
.contact-item:last-child { margin-bottom: 0; }
|
||||
.contact-icon { font-size: 40rpx; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -65,7 +65,7 @@ Page({
|
||||
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
|
||||
return
|
||||
}
|
||||
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
@@ -76,18 +76,11 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections
|
||||
// 优先 book/parts,404 或失败时降级为 all-chapters 推导
|
||||
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections(book/parts,不再用 all-chapters)
|
||||
async loadParts() {
|
||||
this.setData({ partsLoading: true })
|
||||
try {
|
||||
let res
|
||||
try {
|
||||
res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
|
||||
} catch (e) {
|
||||
console.log('[Chapters] book/parts 失败,降级 all-chapters:', e?.message || e)
|
||||
res = null
|
||||
}
|
||||
const res = await app.request({ url: '/api/miniprogram/book/parts', silent: true })
|
||||
let parts = []
|
||||
let totalSections = 0
|
||||
let fixedSections = []
|
||||
@@ -95,21 +88,6 @@ Page({
|
||||
parts = res.parts
|
||||
totalSections = res.totalSections ?? 0
|
||||
fixedSections = res.fixedSections || []
|
||||
} else {
|
||||
// 降级:从 all-chapters 推导 parts
|
||||
const allRes = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const list = (allRes?.data || allRes?.chapters || [])
|
||||
totalSections = list.length
|
||||
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
|
||||
const exclude = (c) => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
|
||||
const partMap = new Map()
|
||||
list.filter(exclude).forEach(c => {
|
||||
const pid = c.partId || c.part_id || 'default'
|
||||
const ptitle = c.partTitle || c.part_title || '未分类'
|
||||
if (!partMap.has(pid)) partMap.set(pid, { id: pid, title: ptitle, subtitle: '', chapterCount: 0 })
|
||||
partMap.get(pid).chapterCount++
|
||||
})
|
||||
parts = Array.from(partMap.values())
|
||||
}
|
||||
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||
const fixedMap = {}
|
||||
@@ -185,7 +163,7 @@ Page({
|
||||
else bookDataFlat.push(r)
|
||||
})
|
||||
app.globalData.bookData = bookDataFlat
|
||||
wx.setStorageSync('bookData', bookDataFlat)
|
||||
wx.setStorage({ key: 'bookData', data: bookDataFlat }) // 异步写入,避免阻塞主线程
|
||||
this.setData({ bookData, _loadedChapters: loaded })
|
||||
} catch (e) {
|
||||
console.log('[Chapters] 加载章节失败:', e)
|
||||
@@ -252,7 +230,7 @@ Page({
|
||||
// 跳转到阅读页(优先传 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}` })
|
||||
|
||||
@@ -314,7 +314,7 @@ Page({
|
||||
goToArticle() {
|
||||
const { detail } = this.data
|
||||
if (!detail || detail.productType !== 'section' || !detail.productId) return
|
||||
const mid = detail.productMid || app.getSectionMid?.(detail.productId)
|
||||
const mid = detail.productMid
|
||||
const q = mid ? `mid=${mid}` : `id=${detail.productId}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
<!-- 安全徽章(发起人视角不展示) -->
|
||||
<view class="security-badge" wx:if="{{!isCreateMode && !isInitiator}}">
|
||||
<text class="security-icon">🛡</text>
|
||||
<icon name="shield" size="40" color="#00CED1" customClass="security-icon"></icon>
|
||||
<text class="security-text">安全支付保障 · 资金由平台托管</text>
|
||||
</view>
|
||||
</block>
|
||||
@@ -145,7 +145,7 @@
|
||||
<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="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}}">
|
||||
|
||||
@@ -128,46 +128,35 @@ Page({
|
||||
initData() {
|
||||
this.setData({ loading: false })
|
||||
this.loadBookData()
|
||||
this.loadFeaturedFromServer()
|
||||
this.loadFeaturedAndLatest()
|
||||
this.loadSuperMembers()
|
||||
this.loadLatestChapters()
|
||||
},
|
||||
|
||||
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
|
||||
}))
|
||||
if (members.length > 0) {
|
||||
console.log('[Index] 超级个体加载成功:', members.length, '人')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Index] vip/members 请求失败:', 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, '人')
|
||||
}
|
||||
// 不足 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 (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) {
|
||||
@@ -176,109 +165,69 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 从服务端获取精选推荐、最新更新(stitch_soul:book/recommended、book/latest-chapters)
|
||||
async loadFeaturedFromServer() {
|
||||
// 精选推荐 + 最新更新 + 最新列表:一次请求 recommended + latest-chapters,避免重复
|
||||
async loadFeaturedAndLatest() {
|
||||
try {
|
||||
// 1. 精选推荐:优先用 book/recommended(按阅读量+算法,带 热门/推荐/精选 标签)
|
||||
let featured = []
|
||||
try {
|
||||
const recRes = await app.request({ url: '/api/miniprogram/book/recommended', silent: true })
|
||||
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
|
||||
featured = recRes.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 || ['热门', '推荐', '精选'][i] || '精选',
|
||||
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
|
||||
}))
|
||||
this.setData({ featuredSections: featured })
|
||||
}
|
||||
} catch (e) { console.log('[Index] book/recommended 失败:', e) }
|
||||
const excludeFixed = (c) => {
|
||||
const pt = (c.part_title || c.partTitle || '').toLowerCase()
|
||||
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
|
||||
}
|
||||
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'
|
||||
})
|
||||
|
||||
// 兜底:无 recommended 时从 book/hot 取前3
|
||||
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) {
|
||||
const tagMap = ['热门', '推荐', '精选']
|
||||
featured = hotList.slice(0, 3).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] || '精选',
|
||||
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
|
||||
}))
|
||||
this.setData({ featuredSections: featured })
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
const tagMap = ['热门', '推荐', '精选']
|
||||
featured = valid
|
||||
.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
|
||||
.slice(0, 3)
|
||||
.map((s, i) => ({
|
||||
id: s.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: tagMap[i] || '精选',
|
||||
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
|
||||
}))
|
||||
this.setData({ featuredSections: featured })
|
||||
}
|
||||
}
|
||||
if (featured.length > 0) this.setData({ featuredSections: featured })
|
||||
|
||||
// 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('附录')
|
||||
// 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 || ''
|
||||
}
|
||||
})
|
||||
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) {
|
||||
// 兜底:从 all-chapters 取
|
||||
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 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)
|
||||
}
|
||||
@@ -286,18 +235,18 @@ Page({
|
||||
|
||||
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 || 62,
|
||||
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 })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -328,7 +277,7 @@ Page({
|
||||
})
|
||||
return
|
||||
}
|
||||
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || {}
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
@@ -349,11 +298,11 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/search/search' })
|
||||
},
|
||||
|
||||
// 跳转到阅读页(优先传 mid,与分享逻辑一致)
|
||||
// 跳转到阅读页(传 mid,与分享一致;无 mid 时传 id)
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
trackClick('home', 'card_click', id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
@@ -588,33 +537,6 @@ Page({
|
||||
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
|
||||
},
|
||||
|
||||
// 最新新增:用 latest-chapters 接口(后端按 updated_at 取前 N 条),不拉全量,支持万级文章
|
||||
async loadLatestChapters() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
|
||||
const list = (res && res.data) ? res.data : []
|
||||
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
|
||||
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
|
||||
const latest = list
|
||||
.filter(exclude)
|
||||
.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: '', // latest-chapters 不返回 content,避免大表全量加载
|
||||
price: c.price ?? 1,
|
||||
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
})
|
||||
const display = this.data.latestExpanded ? latest : latest.slice(0, 5)
|
||||
this.setData({ latestChapters: latest, displayLatestChapters: display })
|
||||
} catch (e) { console.log('[Index] 加载最新新增失败:', e) }
|
||||
},
|
||||
|
||||
goToMemberDetail(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('home', 'card_click', '超级个体_' + (id || ''))
|
||||
@@ -630,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()
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- 搜索栏(根据配置显示) -->
|
||||
<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>
|
||||
|
||||
@@ -14,10 +14,10 @@ const { trackClick } = require('../../utils/trackClick')
|
||||
// 导师顾问:跳转到存客宝添加微信
|
||||
// 团队招募:跳转到存客宝添加微信
|
||||
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 // 每日免费匹配次数
|
||||
@@ -490,9 +490,9 @@ Page({
|
||||
concept: concepts[index % concepts.length],
|
||||
wechat: wechats[index % wechats.length],
|
||||
commonInterests: [
|
||||
{ icon: '📚', text: '都在读《创业派对》' },
|
||||
{ icon: '💼', text: '对私域运营感兴趣' },
|
||||
{ icon: '🎯', text: '相似的创业方向' }
|
||||
{ icon: 'book-open', text: '都在读《创业派对》' },
|
||||
{ icon: 'briefcase', text: '对私域运营感兴趣' },
|
||||
{ icon: 'target', text: '相似的创业方向' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<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>
|
||||
@@ -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,7 +178,7 @@
|
||||
<!-- 头部 -->
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="搜索导师、技能或行业..."
|
||||
|
||||
@@ -116,7 +116,7 @@ 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) || {}
|
||||
const matchEnabled = features.matchEnabled === true
|
||||
const referralEnabled = features.referralEnabled !== false
|
||||
@@ -204,7 +204,7 @@ 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}`
|
||||
}))
|
||||
: []
|
||||
@@ -797,7 +797,7 @@ Page({
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('my', 'card_click', id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
<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="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}}">
|
||||
@@ -204,14 +204,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
|
||||
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
|
||||
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
|
||||
.tag-region { background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
|
||||
.tag-region { display: flex; align-items: center; gap: 8rpx; background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
|
||||
.tag-region .tag-icon { flex-shrink: 0; }
|
||||
|
||||
/* 通用区块 */
|
||||
.section {
|
||||
|
||||
@@ -40,7 +40,7 @@ Page({
|
||||
const orders = purchasedSections.map((id, index) => ({
|
||||
id: `order_${index}`,
|
||||
sectionId: id,
|
||||
sectionMid: app.getSectionMid(id),
|
||||
sectionMid: 0,
|
||||
title: `章节 ${id}`,
|
||||
amount: 1,
|
||||
status: 'completed',
|
||||
@@ -52,7 +52,7 @@ Page({
|
||||
const purchasedSections = app.globalData.purchasedSections || []
|
||||
this.setData({
|
||||
orders: purchasedSections.map((id, i) => ({
|
||||
id: `order_${i}`, sectionId: id, sectionMid: app.getSectionMid(id), title: `章节 ${id}`, amount: 1, status: 'completed',
|
||||
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
|
||||
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,16 +91,21 @@ Page({
|
||||
async onLoad(options) {
|
||||
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
|
||||
|
||||
// 预加载 config:linkTags、auditMode 等(阅读页直接进入时需主动拉取最新审核状态)
|
||||
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
|
||||
// 预加载:core+auditMode(getConfig)+ read-extras 懒加载(linkTags、linkedMiniprograms)
|
||||
Promise.all([
|
||||
app.getConfig(),
|
||||
app.getReadExtras()
|
||||
]).then(([cfg, extras]) => {
|
||||
if (cfg) {
|
||||
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
|
||||
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
|
||||
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、gift(代付)
|
||||
@@ -120,21 +125,15 @@ Page({
|
||||
|
||||
console.log("页面:",mid);
|
||||
|
||||
// mid 有值但无 id 时,从 bookData 或 API 解析 id
|
||||
// 兼容: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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +185,8 @@ Page({
|
||||
readingTracker.init(id)
|
||||
}
|
||||
|
||||
// 5. 加载导航
|
||||
this.loadNavigation(id)
|
||||
// 5. 导航:文章详情已带 prev/next
|
||||
this._applyPrevNext(chapterRes)
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Read] 初始化失败:', e)
|
||||
@@ -342,7 +341,7 @@ Page({
|
||||
return titles[id] || `章节 ${id}`
|
||||
},
|
||||
|
||||
// 根据 id/mid 构造章节接口路径(优先使用 mid)。必须带 userId 才能让后端正确判断付费用户并返回完整内容
|
||||
// 根据 id/mid 构造章节接口路径:优先 mid(by-mid),否则用 id(by-id,兼容旧链接)
|
||||
_getChapterUrl(params = {}) {
|
||||
const { id, mid } = params
|
||||
const finalMid = (mid !== undefined && mid !== null) ? mid : this.data.sectionMid
|
||||
@@ -351,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)
|
||||
@@ -446,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,
|
||||
})
|
||||
},
|
||||
|
||||
// 返回(从分享进入无栈时回首页)
|
||||
@@ -1304,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 }
|
||||
@@ -1545,8 +1519,7 @@ Page({
|
||||
readingTracker.init(this.data.sectionId)
|
||||
}
|
||||
|
||||
// 加载导航
|
||||
this.loadNavigation(this.data.sectionId)
|
||||
this._applyPrevNext(chapterRes)
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '加载成功', icon: 'success' })
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="nav-btn nav-end" wx:else>
|
||||
<text class="btn-end-text">已是最后一篇 🎉</text>
|
||||
<text class="btn-end-text">已是最后一篇</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -95,15 +95,15 @@
|
||||
<view class="action-section">
|
||||
<view class="action-row-inline">
|
||||
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
|
||||
<text class="action-icon-small">📣</text>
|
||||
<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="generatePoster">
|
||||
<text class="action-icon-small">🖼️</text>
|
||||
<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}}">
|
||||
<text class="action-icon-small">🎁</text>
|
||||
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">代付分享</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -126,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>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
</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>
|
||||
@@ -177,7 +177,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>
|
||||
|
||||
@@ -192,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">
|
||||
@@ -206,7 +206,7 @@
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
|
||||
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
<text class="gift-share-icon">🎁</text>
|
||||
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
|
||||
<text class="gift-share-text">代付分享</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -236,7 +236,7 @@
|
||||
</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>
|
||||
@@ -279,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>
|
||||
@@ -292,7 +292,7 @@
|
||||
<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="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="login-title">登录 Soul创业派对</text>
|
||||
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
|
||||
|
||||
@@ -321,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -133,7 +133,7 @@
|
||||
>
|
||||
<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:elif="{{item.status === 'expired'}}" name="clock" size="28" color="#ff9500"></icon>
|
||||
<text wx:else>{{item.nickname[0] || '用'}}</text>
|
||||
</view>
|
||||
<view class="user-info">
|
||||
|
||||
@@ -112,7 +112,7 @@ Page({
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
trackClick('search', 'card_click', id || '章节')
|
||||
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<text class="back-icon">←</text>
|
||||
</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="搜索章节标题或内容..."
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
<!-- 无结果 -->
|
||||
<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>
|
||||
|
||||
@@ -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,7 +24,7 @@
|
||||
<!-- 手机号 - 使用微信一键获取 -->
|
||||
<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>
|
||||
@@ -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
|
||||
@@ -61,7 +61,7 @@
|
||||
<!-- 收货地址 - 跳转到地址管理页 -->
|
||||
<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>
|
||||
|
||||
@@ -12,15 +12,15 @@ Page({
|
||||
originalPrice: 6980,
|
||||
/* 按 premium_membership_landing_v1 设计稿 */
|
||||
contentRights: [
|
||||
{ title: '解锁全部章节', desc: '365天全案精读', icon: '📖' },
|
||||
{ title: '案例库', desc: '100+创业实战案例', icon: '📚' },
|
||||
{ title: '智能纪要', desc: 'AI每日精华推送', 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: '精准人脉匹配', icon: '👥' },
|
||||
{ title: '创业老板排行', desc: '项目曝光展示', icon: '📊' },
|
||||
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
|
||||
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: 'users' },
|
||||
{ title: '创业老板排行', desc: '项目曝光展示', icon: 'bar-chart' },
|
||||
{ title: '链接资源', desc: '深度私域资源池', icon: 'link' },
|
||||
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
|
||||
],
|
||||
purchasing: false
|
||||
|
||||
@@ -23,7 +23,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>
|
||||
@@ -36,7 +36,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>
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
<view class="transactions" wx:if="{{transactions.length > 0}}">
|
||||
<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">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
|
||||
* 整行可拖拽;节和章可跨篇
|
||||
*/
|
||||
@@ -347,8 +347,8 @@ export function ChapterTree({
|
||||
)
|
||||
}
|
||||
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放
|
||||
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放(以 part_id 识别,标题来自 DB)
|
||||
const is2026Daily = part.id === 'part-2026-daily'
|
||||
if (is2026Daily) {
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
return (
|
||||
|
||||
@@ -158,32 +158,21 @@ function buildTree(sections: SectionListItem[]): Part[] {
|
||||
hotRank: s.hotRank ?? 0,
|
||||
})
|
||||
}
|
||||
// 确保「2026每日派对干货」篇章存在(不在第六篇编号体系内)
|
||||
const DAILY_PART_ID = 'part-2026-daily'
|
||||
const DAILY_PART_TITLE = '2026每日派对干货'
|
||||
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
|
||||
if (!hasDailyPart) {
|
||||
partMap.set(DAILY_PART_ID, {
|
||||
id: DAILY_PART_ID,
|
||||
title: DAILY_PART_TITLE,
|
||||
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
|
||||
})
|
||||
}
|
||||
const parts = Array.from(partMap.values()).map((p) => ({
|
||||
...p,
|
||||
chapters: Array.from(p.chapters.values()),
|
||||
}))
|
||||
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
|
||||
const orderKey = (t: string) => {
|
||||
if (t.includes('序言')) return 0
|
||||
if (t.includes(DAILY_PART_TITLE)) return 1.5
|
||||
if (t.includes('附录')) return 2
|
||||
if (t.includes('尾声')) return 3
|
||||
// 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB
|
||||
const orderKey = (p: { id: string; title: string }) => {
|
||||
if (p.title.includes('序言')) return 0
|
||||
if (p.id === 'part-2026-daily') return 1.5
|
||||
if (p.title.includes('附录')) return 2
|
||||
if (p.title.includes('尾声')) return 3
|
||||
return 1
|
||||
}
|
||||
return parts.sort((a, b) => {
|
||||
const ka = orderKey(a.title)
|
||||
const kb = orderKey(b.title)
|
||||
const ka = orderKey(a)
|
||||
const kb = orderKey(b)
|
||||
if (ka !== kb) return ka - kb
|
||||
return 0
|
||||
})
|
||||
|
||||
14
soul-api/internal/cache/cache.go
vendored
14
soul-api/internal/cache/cache.go
vendored
@@ -44,6 +44,9 @@ 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 {
|
||||
@@ -156,9 +159,13 @@ func InvalidateBookCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
|
||||
// InvalidateConfig 配置变更时调用,使小程序 config 及拆分接口缓存失效
|
||||
func InvalidateConfig() {
|
||||
Del(context.Background(), KeyConfigMiniprogram)
|
||||
ctx := context.Background()
|
||||
Del(ctx, KeyConfigMiniprogram)
|
||||
Del(ctx, KeyConfigAuditMode)
|
||||
Del(ctx, KeyConfigCore)
|
||||
Del(ctx, KeyConfigReadExtras)
|
||||
}
|
||||
|
||||
// BookRelatedTTL 书籍相关接口 TTL(hot/recommended/stats)
|
||||
@@ -167,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) }
|
||||
|
||||
|
||||
@@ -223,6 +223,10 @@ func WarmBookPartsCache() {
|
||||
}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
//
|
||||
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part + book/stats,id↔mid 从各接口响应积累。
|
||||
// 保留以兼容旧版/管理端,计划后续下线。
|
||||
//
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
@@ -398,6 +402,29 @@ func BookChaptersByPart(c *gin.Context) {
|
||||
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")
|
||||
@@ -618,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 {
|
||||
|
||||
@@ -26,19 +26,19 @@ func buildMiniprogramConfig() gin.H {
|
||||
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",
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"mchId": "1318592501",
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"success": true,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
@@ -143,6 +143,8 @@ func buildMiniprogramConfig() gin.H {
|
||||
|
||||
// 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 {
|
||||
@@ -154,10 +156,121 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// WarmConfigCache 启动时预热 config 缓存,避免首请求冷启动
|
||||
// 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 组处理时用)
|
||||
@@ -196,13 +309,13 @@ 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),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"mchId": "1318592501",
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
@@ -313,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
|
||||
@@ -361,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)
|
||||
@@ -480,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
|
||||
@@ -571,7 +684,7 @@ func DBUsersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook)
|
||||
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -744,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": "请求体无效"})
|
||||
@@ -788,25 +901,25 @@ func DBUsersAction(c *gin.Context) {
|
||||
}
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio;tags 存 ckb_tags)
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
IsVip *bool `json:"isVip"`
|
||||
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
|
||||
VipSort *int `json:"vipSort"` // 手动排序,越小越前
|
||||
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `json:"vipName"`
|
||||
VipAvatar *string `json:"vipAvatar"`
|
||||
VipProject *string `json:"vipProject"`
|
||||
VipContact *string `json:"vipContact"`
|
||||
VipBio *string `json:"vipBio"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
@@ -1001,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,
|
||||
})
|
||||
}
|
||||
@@ -1110,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
|
||||
}
|
||||
|
||||
@@ -111,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)
|
||||
@@ -134,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)
|
||||
|
||||
// ----- 内容 -----
|
||||
@@ -277,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)
|
||||
@@ -287,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)
|
||||
|
||||
5
soul-api/scripts/fix-part-titles.sql
Normal file
5
soul-api/scripts/fix-part-titles.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 修复篇章标题:将 slug 形式的 part_title 更新为展示标题(数据来源:DB)
|
||||
-- 执行:node .cursor/scripts/db-exec/run.js -f soul-api/scripts/fix-part-titles.sql
|
||||
|
||||
-- part-2026-daily 的标题应为「2026每日派对干货」
|
||||
UPDATE chapters SET part_title = '2026每日派对干货' WHERE part_id = 'part-2026-daily' AND (part_title = 'part-2026-daily' OR part_title = '' OR part_title IS NULL);
|
||||
@@ -37453,3 +37453,7 @@
|
||||
{"level":"debug","timestamp":"2026-03-18T11:51:58+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 18 Mar 2026 03:51:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08DFC2E8CD06100618C39F85AB0120AEEC1C289B9804-0\r\nServer: nginx\r\nWechatpay-Nonce: 17660221f926159ccf7b6ed6f4b81a7d\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: NoR8AwrKE2Yh6P+h0VswlALQ0Ey82PxztzQUi2egtp05gVARPBe3pPaIQBHEKAaMWJhUiOlAe8Xp5RT3Pl9a04JYlwfsGQ1hmII+DAVvqvIWJp2OISAyiwqkve6+aTlHJ4YQh8a7h8loxGSGIOMGsnCMYH3vqGSVN8Yz16munthcCspD1Alft06aD2Tyo1SIa3WV0B6SF+po8qBR8OQcy31+ubHMVKbN+Sxf2AXLvKfy1VafALUT/IDb7w7Qu4SbzAHJIubokOhrU+a8cc4kIzPsntwPN+66Wb5jybTVQrxvG7MCpffrshskCMiGYJPsDQsDwLvg1UL8yEYuxDSeUw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773805919\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260318114741238800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-18T11:56:58+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260318114741238800?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"kQb3R3YEPZwaIVyvHXnzmWhMxi7REUev\",timestamp=\"1773806218\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"jRP0P/vCLRRWJNZK78glwhI6mX2AabFim0k0BHfZek7g/YWel0VojRqUeAsudxMCyk5Npd51en9O7KkVC9dZ1zS+zn93MnqEUozR7mKje4FTD6cidJwuGHqaij7W2aYPTqBPGKURsAGpJOrspHvxCAlAofKXM4PfjGPaYtQaUqrq5cdolkSP6/t3ZgwWEUafDZ7kiMb+34wv6hLNCM3WfygXcED0A8n1yPN0cUKIEdDlLjhDPDQD+qlYglUCjx7NIq7w4CwFW9pDxXJRyNM7DNgjVgYNLrgRz1mo4oiYJJCkO+BKG7vmixc2ttVnzsq2VXE+yTu0qXT0m2iOA8j3VQ==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-18T11:56:58+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 18 Mar 2026 03:56:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 088BC5E8CD06100F18DDB1C05520BAF3212883E701-0\r\nServer: nginx\r\nWechatpay-Nonce: 9dcbc99bd1add75dab4b137a5044c426\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: EpRCntgRS4NDqc/nc1PzCXhdJtzh8102uDzjNdT7xHfQJ0spKF9BPg6a2/M0iWMKL57lYWC2s8v+Vo73WxIL+kKeHwziaAt7IlDj7D4BqRvWN5AbE2JG/+kq4DeI5mWj0wDJdq1jNsenvRvrsZ3rPRUXJYwg+wbGsRj2sZ5KSEqdS28ImXJGJvRdKSdNAKiorrag8v7Jzd/h/ESJ7sWslWa9/y2GNTG9c1/WTs8374FoyoTNTd+bE/+dlRjeiNVwVCyxjr2g/4A0RZlqtxAZcBpvb3/d70ZyO+vrSN9Wc2ttMjGycXX3D7XRd1ZVgpA89cF/j4WWTaX5Jwvj3kI0Ww==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773806219\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260318114741238800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Wed, 18 Mar 2026 07:43:09 GMT\r\n\r\n{\"access_token\":\"102_W92JqCHafxmMPmqerkx_H3KLFbch7Gn0EQESDosCLzzg3wIoVw5D6dwdO_n8t8-rC1JXBEB9niZwtll6b0CENMM8vPydT8k7-gk0pg15AMl_vTZPbq_ju0ELnJsSRLcADAGFZ\",\"expires_in\":7200}"}
|
||||
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_W92JqCHafxmMPmqerkx_H3KLFbch7Gn0EQESDosCLzzg3wIoVw5D6dwdO_n8t8-rC1JXBEB9niZwtll6b0CENMM8vPydT8k7-gk0pg15AMl_vTZPbq_ju0ELnJsSRLcADAGFZ&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0d1RnJkl2eGlnh46F3ml2JJFVe0RnJks&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Wed, 18 Mar 2026 07:43:10 GMT\r\n\r\n{\"session_key\":\"mur32y6Vm+aQjbMsDdhZew==\",\"openid\":\"ogpTW5a9exdEmEwqZsYywvgSpSQg\"}"}
|
||||
|
||||
163
开发文档/config接口优化分析.md
Normal file
163
开发文档/config接口优化分析.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Config 接口优化分析
|
||||
|
||||
## 一、现状
|
||||
|
||||
### 1.1 当前 config 接口
|
||||
|
||||
- **路径**:`GET /api/miniprogram/config`
|
||||
- **Handler**:`GetPublicDBConfig`(soul-api/internal/handler/db.go)
|
||||
- **缓存**:Redis 10min,配置变更时 `InvalidateConfig()` 失效
|
||||
|
||||
### 1.2 返回内容(buildMiniprogramConfig)
|
||||
|
||||
| 字段 | 来源 | 小程序使用场景 | 使用频率 |
|
||||
|-----|------|----------------|----------|
|
||||
| **prices** | chapter_config | 阅读页解锁价格、chapterAccessManager | 中 |
|
||||
| **features** | chapter_config + feature_config | 首页/目录/我的/TabBar:searchEnabled、matchEnabled、referralEnabled | 高 |
|
||||
| **mpConfig** | mp_config | auditMode、appId、mchId、withdrawSubscribeTmplId、supportWechat | 高(auditMode)/ 低(支付) |
|
||||
| **linkTags** | link_tags 表 | 阅读页 onLinkTagTap、contentParser | 低(仅阅读页) |
|
||||
| **linkedMiniprograms** | system_config | 阅读页跳转关联小程序 | 低(仅阅读页) |
|
||||
| **userDiscount** | referral_config | 阅读页优惠价展示 | 低 |
|
||||
| **configs** | 原始配置 | 备用格式 | 低 |
|
||||
|
||||
### 1.3 管理端与审核模式
|
||||
|
||||
- 管理端 SettingsPage 通过 `/api/admin/settings` 读写 `mp_config`,其中 `auditMode` 由管理端开关控制
|
||||
- 审核模式开启时:小程序隐藏支付、提现、VIP 等 UI
|
||||
- 小程序从后台切回时需刷新 auditMode(onShow 节流 30s)
|
||||
|
||||
---
|
||||
|
||||
## 二、优化目标
|
||||
|
||||
1. **审核模式独立**:管理端控制,需单独接口,不影响线上 config
|
||||
2. **config 保留**:向后兼容,不破坏现有小程序
|
||||
3. **拆分按需加载**:提升首屏/预览速度,减少单次请求体积
|
||||
4. **废弃标记**:对后续计划废弃的接口在代码中标记
|
||||
|
||||
---
|
||||
|
||||
## 三、拆分方案
|
||||
|
||||
### 3.1 新增接口
|
||||
|
||||
| 接口 | 用途 | 返回内容 | 缓存 | 调用时机 |
|
||||
|-----|------|----------|------|----------|
|
||||
| **GET /api/miniprogram/config/audit-mode** | 审核模式(独立) | `{ auditMode: boolean }` | 1min(可更短) | onShow、阅读页 onLoad、支付前 |
|
||||
| **GET /api/miniprogram/config/core** | 核心配置(精简) | prices、features、userDiscount | 10min | 首屏、Tab 切换、getConfig 主入口 |
|
||||
| **GET /api/miniprogram/config/read-extras** | 阅读页扩展 | linkTags、linkedMiniprograms | 10min | 阅读页 onLoad 懒加载 |
|
||||
|
||||
### 3.2 保留接口(加废弃标记)
|
||||
|
||||
| 接口 | 标记 | 说明 |
|
||||
|-----|------|------|
|
||||
| **GET /api/miniprogram/config** | `@deprecated` | 保留至小程序全部迁移到 core + audit-mode + read-extras 后废弃 |
|
||||
|
||||
### 3.3 数据流示意
|
||||
|
||||
```
|
||||
首屏 / Tab 切换
|
||||
→ app.getConfig() 优先请求 /config/core(轻量)
|
||||
→ 并行或单独请求 /config/audit-mode(从后台切回时必拉)
|
||||
|
||||
阅读页 onLoad
|
||||
→ 若已有 config 缓存,用 linkTags/linkedMiniprograms
|
||||
→ 若无,懒加载 /config/read-extras
|
||||
|
||||
支付 / 提现前
|
||||
→ 拉取 audit-mode 确认非审核态
|
||||
→ mpConfig(appId、mchId 等)可从 core 或保留在 config 中,按需
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、实施步骤建议
|
||||
|
||||
### 阶段一:新增接口(不破坏现有)
|
||||
|
||||
1. **后端**:新增 `GetAuditMode`、`GetCoreConfig`、`GetReadExtras` 三个 handler
|
||||
2. **缓存**:audit-mode 单独 key、短 TTL;core、read-extras 复用或新建 key
|
||||
3. **管理端**:保存 mp_config 时,除 `InvalidateConfig()` 外,增加 `InvalidateAuditMode()`(若 audit-mode 单独缓存)
|
||||
|
||||
### 阶段二:小程序迁移
|
||||
|
||||
1. **app.getConfig()**:改为请求 `/config/core`,内部合并 audit-mode(可并行请求)
|
||||
2. **app.getAuditMode()**:新增,单独拉取 audit-mode,用于 onShow 刷新
|
||||
3. **阅读页**:优先用 `app.globalData.linkTagsConfig`,无则请求 `/config/read-extras`
|
||||
|
||||
### 阶段三:废弃标记
|
||||
|
||||
1. **db.go**:`GetPublicDBConfig` 注释加 `@deprecated 计划迁移至 /config/core + /config/audit-mode + /config/read-extras`
|
||||
2. **router.go**:`miniprogram.GET("/config", ...)` 上方加废弃说明
|
||||
3. **小程序**:保留对 `/config` 的兼容逻辑,逐步移除
|
||||
|
||||
---
|
||||
|
||||
## 五、接口详细设计
|
||||
|
||||
### 5.1 GET /api/miniprogram/config/audit-mode
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{ "auditMode": false }
|
||||
```
|
||||
|
||||
**缓存**:`soul:config:audit-mode`,TTL 60s(审核开关后需较快生效)
|
||||
|
||||
**失效**:管理端保存 mp_config 时调用 `InvalidateAuditMode()`
|
||||
|
||||
### 5.2 GET /api/miniprogram/config/core
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"prices": { "section": 1, "fullbook": 9.9 },
|
||||
"features": { "matchEnabled": true, "referralEnabled": true, "searchEnabled": true },
|
||||
"userDiscount": 5
|
||||
}
|
||||
```
|
||||
|
||||
**缓存**:`soul:config:core`,TTL 10min,与现有 config 变更联动
|
||||
|
||||
### 5.3 GET /api/miniprogram/config/read-extras
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"linkTags": [...],
|
||||
"linkedMiniprograms": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**缓存**:`soul:config:read-extras`,TTL 10min,link_tags / linked_miniprograms 变更时失效
|
||||
|
||||
---
|
||||
|
||||
## 六、性能与兼容
|
||||
|
||||
| 指标 | 当前 config | 拆分后(首屏) |
|
||||
|------|-------------|----------------|
|
||||
| 首请求体量 | 全量(含 linkTags、linkedMiniprograms、configs) | core + audit-mode,体积约 1/3 |
|
||||
| 阅读页 | 依赖全量 config | 无 linkTags 时再请求 read-extras |
|
||||
| 审核模式刷新 | 拉全量 config | 仅拉 audit-mode |
|
||||
| 兼容性 | - | 保留 /config,加 @deprecated |
|
||||
|
||||
---
|
||||
|
||||
## 七、废弃标记清单(代码位置)
|
||||
|
||||
| 文件 | 位置 | 状态 |
|
||||
|------|------|------|
|
||||
| soul-api/internal/handler/db.go | GetPublicDBConfig 函数注释 | ✅ 已加 Deprecated |
|
||||
| soul-api/internal/router/router.go | miniprogram.GET("/config", ...) 上方 | ✅ 已加 Deprecated |
|
||||
| miniprogram/app.js | getConfig 已迁移至 core+audit-mode | ✅ 已实现 |
|
||||
|
||||
---
|
||||
|
||||
## 八、总结
|
||||
|
||||
- **审核模式**:独立接口 `/config/audit-mode`,管理端开关后快速生效,不依赖全量 config
|
||||
- **config 保留**:继续可用,加废弃标记,便于后续统一下线
|
||||
- **拆分策略**:core(首屏必需)、audit-mode(独立)、read-extras(阅读页懒加载)
|
||||
- **迁移节奏**:先上新接口,小程序逐步切到 core + audit-mode + read-extras,最后废弃 /config
|
||||
147
开发文档/小程序-图标清单-阿里云iconfont.md
Normal file
147
开发文档/小程序-图标清单-阿里云iconfont.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Soul 创业派对 - 小程序图标清单(阿里云 iconfont 采购用)
|
||||
|
||||
> 用于在 [iconfont.cn](https://www.iconfont.cn) 或阿里云设计资源中搜索、下载。建议统一风格(线性/描边),尺寸 24×24 或 48×48。
|
||||
|
||||
---
|
||||
|
||||
## 一、icon 组件内联 SVG(需对应 name)
|
||||
|
||||
当前 `components/icon/icon.js` 使用 Lucide 风格,若改为阿里云图标,需按以下 name 一一对应:
|
||||
|
||||
| 序号 | name | 中文名 | 用途/场景 |
|
||||
|-----|------|--------|----------|
|
||||
| 1 | share | 分享 | 分享到好友/朋友圈 |
|
||||
| 2 | chevron-left | 左箭头 | 返回、导航 |
|
||||
| 3 | chevron-right | 右箭头 | 进入、下一步 |
|
||||
| 4 | chevron-down | 下箭头 | 展开、下拉 |
|
||||
| 5 | search | 搜索 | 搜索框、目录搜索 |
|
||||
| 6 | user | 用户 | 个人中心、头像占位 |
|
||||
| 7 | users | 用户组 | 找伙伴、资源对接 |
|
||||
| 8 | home | 首页 | 首页入口 |
|
||||
| 9 | star | 星星 | 找伙伴、收藏 |
|
||||
| 10 | heart | 心形 | 导师顾问、喜欢 |
|
||||
| 11 | message-circle | 消息/对话 | 微信、联系方式 |
|
||||
| 12 | smartphone | 手机 | 手机号、绑定 |
|
||||
| 13 | map-pin | 地图定位 | 地区、地址 |
|
||||
| 14 | book-open | 打开的书 | 书籍、阅读、目录 |
|
||||
| 15 | lightbulb | 灯泡 | 想法、亮点 |
|
||||
| 16 | handshake | 握手 | 合作、我能帮到你 |
|
||||
| 17 | rocket | 火箭 | 成长、我能帮到你 |
|
||||
| 18 | trophy | 奖杯 | 最赚钱的一个月 |
|
||||
| 19 | star | 星星 | 最有成就感的一件事 |
|
||||
| 20 | refresh-cw | 刷新 | 人生的转折点 |
|
||||
| 21 | shield | 盾牌 | 安全、代付保障 |
|
||||
| 22 | wallet | 钱包 | 余额、支付、提现 |
|
||||
| 23 | camera | 相机 | 头像上传 |
|
||||
| 24 | lock | 锁 | 登录、付费墙 |
|
||||
| 25 | gift | 礼物 | 代付、礼包 |
|
||||
| 26 | megaphone | 喇叭 | 分享到朋友圈 |
|
||||
| 27 | image | 图片 | 生成海报 |
|
||||
| 28 | clipboard | 剪贴板 | 复制联系方式 |
|
||||
| 29 | check | 勾选 | 同意协议、已绑定 |
|
||||
| 30 | trash-2 | 删除 | 删除地址 |
|
||||
| 31 | plus | 加号 | 新增 |
|
||||
| 32 | pencil | 编辑 | 编辑资料、编辑地址 |
|
||||
| 33 | zap | 闪电 | 购买次数、解锁 |
|
||||
| 34 | info | 信息 | 温馨提示 |
|
||||
| 35 | x | 关闭 | 弹窗关闭 |
|
||||
| 36 | gamepad | 手柄 | 团队招募 |
|
||||
| 37 | briefcase | 公文包 | 对私域运营感兴趣 |
|
||||
| 38 | target | 靶心 | 相似的创业方向 |
|
||||
| 39 | save | 保存 | 保存海报 |
|
||||
| 40 | globe | 地球 | 朋友圈 |
|
||||
| 41 | package | 包裹 | 空状态、订单 |
|
||||
| 42 | clock | 时钟 | 时间、过期 |
|
||||
| 43 | corner-down-left | 退款 | 退款类型 |
|
||||
| 44 | folder | 文件夹 | 目录 |
|
||||
|
||||
---
|
||||
|
||||
## 二、assets/icons 独立文件(image 引用)
|
||||
|
||||
当前 `miniprogram/assets/icons/` 下已有,若需阿里云统一风格可替换:
|
||||
|
||||
| 文件名 | 用途 |
|
||||
|--------|------|
|
||||
| home.svg | 底部 Tab 首页 |
|
||||
| list.svg | 底部 Tab 目录 |
|
||||
| user.svg | 底部 Tab 我的 |
|
||||
| partners.svg | 底部 Tab 找伙伴(中间突出) |
|
||||
| edit-gray.svg | 我的页编辑、设置入口 |
|
||||
| wallet.svg | 我的余额、提现 |
|
||||
| eye-teal.svg | 阅读统计 |
|
||||
| book-open-teal.svg | 阅读统计 |
|
||||
| clock-teal.svg | 阅读统计 |
|
||||
| users-teal.svg | 阅读统计 |
|
||||
| book-arrow-teal.svg | 阅读统计 |
|
||||
| folder-teal.svg | 目录入口 |
|
||||
| gift.svg | 代付入口 |
|
||||
| settings-gray.svg | 设置入口 |
|
||||
| chevron-left.svg | 返回 |
|
||||
| arrow-right.svg | 右箭头 |
|
||||
| bell.svg | 消息提醒 |
|
||||
| alert-circle.svg | 警告提示 |
|
||||
| users.svg | 绑定用户 |
|
||||
| image.svg | 分享 |
|
||||
| share.svg | 分享 |
|
||||
| message-circle.svg | 微信分享 |
|
||||
| user.svg | 用户占位 |
|
||||
| gift.svg | 礼包空状态 |
|
||||
| info.svg | 礼包页说明 |
|
||||
| eye-off.svg | 未解锁(联系方式/微信号) |
|
||||
| user-edit-gray.svg | (备用) |
|
||||
| info-blue.svg | (备用) |
|
||||
| book.svg | (备用) |
|
||||
| book-arrow.svg | (备用) |
|
||||
| clock.svg | (备用) |
|
||||
| eye.svg | (备用) |
|
||||
| folder.svg | (备用) |
|
||||
| settings.svg | (备用) |
|
||||
|
||||
**PNG(Tab 激活态,可考虑改为 SVG):**
|
||||
- home.png / home-active.png
|
||||
- my.png / my-active.png
|
||||
- match.png / match-active.png
|
||||
|
||||
---
|
||||
|
||||
## 三、iconfont 搜索关键词建议
|
||||
|
||||
| 图标 | 搜索词 |
|
||||
|------|--------|
|
||||
| 分享 | 分享、share |
|
||||
| 返回 | 左箭头、返回、chevron |
|
||||
| 箭头 | 右箭头、arrow、chevron |
|
||||
| 搜索 | 搜索、search、放大镜 |
|
||||
| 用户 | 用户、user、person |
|
||||
| 用户组 | 用户组、users、people |
|
||||
| 首页 | 首页、home、房子 |
|
||||
| 星星 | 星星、star |
|
||||
| 心形 | 心、heart、喜欢 |
|
||||
| 消息 | 消息、对话、message、微信 |
|
||||
| 手机 | 手机、smartphone |
|
||||
| 定位 | 定位、地图、map、pin |
|
||||
| 书本 | 书本、book、阅读 |
|
||||
| 灯泡 | 灯泡、lightbulb、想法 |
|
||||
| 握手 | 握手、handshake |
|
||||
| 火箭 | 火箭、rocket |
|
||||
| 奖杯 | 奖杯、trophy |
|
||||
| 盾牌 | 盾牌、shield |
|
||||
| 钱包 | 钱包、wallet |
|
||||
| 相机 | 相机、camera |
|
||||
| 锁 | 锁、lock |
|
||||
| 礼物 | 礼物、gift |
|
||||
| 勾选 | 勾选、check、对勾 |
|
||||
| 删除 | 删除、trash |
|
||||
| 编辑 | 编辑、pencil、铅笔 |
|
||||
| 闪电 | 闪电、zap |
|
||||
| 信息 | 信息、info |
|
||||
|
||||
---
|
||||
|
||||
## 四、规格建议
|
||||
|
||||
- **格式**:SVG(优先,可缩放)或 PNG
|
||||
- **尺寸**:24×24、32×32、48×48(多尺寸备选)
|
||||
- **风格**:线性描边(stroke),与 Lucide 风格一致
|
||||
- **颜色**:单色或透明底,便于在代码中动态设置 `color`
|
||||
67
开发文档/小程序变更影响分析-20260318.md
Normal file
67
开发文档/小程序变更影响分析-20260318.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 小程序变更影响分析(2026-03-18)
|
||||
|
||||
## 一、近期变更汇总
|
||||
|
||||
| 变更项 | 影响 |
|
||||
|--------|------|
|
||||
| config 拆分 | core + audit-mode + read-extras,已迁移 |
|
||||
| all-chapters 废弃 | 改用 book/parts、chapters-by-part、book/stats |
|
||||
| idMidMap/getSectionMid 移除 | 列表/接口均带 mid,跳转用 mid 或 id |
|
||||
| 章节接口 | by-mid、by-id,移除 /chapter/:id |
|
||||
| 文章详情 | 响应含 prev/next,无需单独请求 |
|
||||
|
||||
---
|
||||
|
||||
## 二、各页面检查结果
|
||||
|
||||
### ✅ 无影响
|
||||
|
||||
| 页面 | 说明 |
|
||||
|------|------|
|
||||
| **index** | recommended/hot/latest 均带 mid,goToRead 用 dataset |
|
||||
| **chapters** | book/parts + chapters-by-part,section 有 mid,fixedSections 有 mid |
|
||||
| **search** | 搜索结果带 id+mid |
|
||||
| **my** | recentChapters 来自 dashboard-stats,后端返回 id+mid |
|
||||
| **read** | _getChapterUrl 支持 by-mid/by-id,prev/next 在详情内 |
|
||||
| **gift-pay** | detail 含 productMid+productId,goToRead 优先 mid |
|
||||
| **purchases** | 订单列表无跳转阅读,sectionMid 兜底为 0 |
|
||||
|
||||
### ✅ 已优化
|
||||
|
||||
| 位置 | 变更 |
|
||||
|------|------|
|
||||
| **read 海报 scene** | 有 sectionMid 时用 mid,与分享一致 |
|
||||
|
||||
### 可选优化(低优先级)
|
||||
|
||||
| 位置 | 现状 | 说明 |
|
||||
|------|------|------|
|
||||
| **read copyLink** | `soul.quwanzhi.com/read/${sectionId}` | H5 若仅支持 id,保持即可 |
|
||||
| **index bookData** | data 中仍有 bookData: [] | 未在 wxml 展示,可后续清理 |
|
||||
|
||||
### 📋 数据流确认
|
||||
|
||||
| 数据源 | id | mid | 说明 |
|
||||
|--------|----|-----|------|
|
||||
| recommended | ✓ | ✓ | toSection 映射 |
|
||||
| latest-chapters | ✓ | ✓ | 直接使用 |
|
||||
| hot | ✓ | ✓ | 直接使用 |
|
||||
| chapters-by-part | ✓ | ✓ | section 含 mid |
|
||||
| book/parts fixedSections | id | mid | fixedSectionsMap |
|
||||
| dashboard-stats recentChapters | ✓ | ✓ | 后端查 chapters 表 |
|
||||
| gift-pay detail | productId | productMid | 后端查 chapters 表 |
|
||||
|
||||
---
|
||||
|
||||
## 三、潜在风险点
|
||||
|
||||
1. **序言/尾声**:fixedSectionsMap 来自 book/parts 的 fixedSections,若后端未返回 mid,data-mid 为空,goToRead 会传 id。需确认 book/parts 的 fixedSections 是否含 mid。
|
||||
2. **orders API**:purchases 从 /api/miniprogram/orders 取数,有 section_mid 时正常;fallback 到 purchasedSections 时 sectionMid=0,当前无跳转阅读,无影响。
|
||||
3. **H5 链接**:copyLink 的 soul.quwanzhi.com 若仅支持 id,保持现状即可。
|
||||
|
||||
---
|
||||
|
||||
## 四、结论
|
||||
|
||||
- **核心流程**:首页、目录、搜索、我的、阅读、代付、分享均正常。
|
||||
- **建议**:海报 scene 改为优先 mid;index bookData 可清理。
|
||||
142
开发文档/小程序性能分析-20260318.md
Normal file
142
开发文档/小程序性能分析-20260318.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Soul 创业派对 - 小程序性能分析(2026-03-18)
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
| 层级 | 策略 | 说明 |
|
||||
|------|------|------|
|
||||
| **前端** | 并行请求、懒加载、GET 去重 | app.request 同 URL 并发共享 promise |
|
||||
| **前端** | 配置缓存 5min | getConfig 减少重复请求 |
|
||||
| **后端** | Redis + 内存双层缓存 | 启动预热,冷启动 502 已规避 |
|
||||
| **后端** | 按需加载 | book/parts、chapters-by-part 懒加载,无全量 all-chapters |
|
||||
|
||||
---
|
||||
|
||||
## 二、启动与首屏
|
||||
|
||||
### 2.1 App onLaunch
|
||||
|
||||
| 任务 | 接口 | 并行 | 说明 |
|
||||
|------|------|------|------|
|
||||
| loadBookData | book/parts | ✓ | 仅取 totalSections |
|
||||
| loadMpConfig | config/core + audit-mode | ✓ | Promise.all 并行 |
|
||||
| checkUpdate | wx.getUpdateManager | ✓ | 系统 API |
|
||||
| handleReferralCode | 本地/可选请求 | ✓ | 推荐码绑定 |
|
||||
|
||||
**结论**:启动阶段请求少,config 已拆分,体积小。
|
||||
|
||||
### 2.2 首页 index initData
|
||||
|
||||
| 任务 | 接口 | 并行 | 说明 |
|
||||
|------|------|------|------|
|
||||
| loadBookData | book/parts | ✓ | 与 app 同 URL,GET 去重 |
|
||||
| loadFeaturedFromServer | recommended + latest-chapters | ✓ | Promise.all |
|
||||
| loadSuperMembers | vip/members → users | 串行 | 不足 4 人时补 users |
|
||||
| loadLatestChapters | latest-chapters | ✓ | 与 loadFeatured 同 URL,去重 |
|
||||
|
||||
**首屏请求汇总**(去重后):
|
||||
- book/parts × 1
|
||||
- config/core + audit-mode × 1(app 已拉)
|
||||
- book/recommended × 1
|
||||
- book/latest-chapters × 1
|
||||
- vip/members × 1
|
||||
- users(条件) × 1
|
||||
|
||||
**优化点**:
|
||||
- loadFeaturedFromServer 与 loadLatestChapters 都请求 latest-chapters,并发时会被去重,但逻辑重复,可合并为一次请求、两处使用。
|
||||
|
||||
---
|
||||
|
||||
## 三、核心页面
|
||||
|
||||
### 3.1 目录页 chapters
|
||||
|
||||
| 阶段 | 接口 | 说明 |
|
||||
|------|------|------|
|
||||
| onLoad | book/parts | 仅篇章列表 + totalSections + fixedSections |
|
||||
| 展开篇章 | chapters-by-part | 按需懒加载,每篇章一次 |
|
||||
|
||||
**潜在问题**:`loadChaptersByPart` 每次展开都会 `wx.setStorageSync('bookData', bookDataFlat)`,章节多时可能阻塞主线程,可考虑节流或异步写入。
|
||||
|
||||
### 3.2 阅读页 read
|
||||
|
||||
| 阶段 | 接口 | 说明 |
|
||||
|------|------|------|
|
||||
| onLoad 预加载 | getConfig + getReadExtras | Promise.all,read-extras 懒加载 |
|
||||
| mid 解析 | chapter/by-mid/:mid | 仅 mid 有值且无 id 时 |
|
||||
| 主流程 | chapter/by-mid 或 by-id | 单次请求 |
|
||||
| 权限 | check-purchased | 非免费章节且已登录时 |
|
||||
| 内容 | 复用 chapterRes | 无二次请求 |
|
||||
|
||||
**结论**:章节数据一次拉取、复用,prev/next 在详情内返回,无额外接口。
|
||||
|
||||
### 3.3 阅读追踪 readingTracker
|
||||
|
||||
| 行为 | 频率 | 说明 |
|
||||
|------|------|------|
|
||||
| 初始化 | 立即上报 1 次 | 打开即记录点击 |
|
||||
| 定期上报 | 每 30 秒 | 增量 duration,不重复累加 |
|
||||
| 读完 | 立即上报 | progress≥90% 且停留 3 秒 |
|
||||
|
||||
**结论**:上报策略合理,对性能影响小。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端缓存
|
||||
|
||||
| 接口 | Redis TTL | 内存 TTL | 预热 |
|
||||
|------|-----------|----------|------|
|
||||
| config/core | 10min | - | ✓ |
|
||||
| config/audit-mode | 1min | - | ✓ |
|
||||
| config/read-extras | 10min | - | ✓ |
|
||||
| book/parts | 10min | 30s | ✓ |
|
||||
| chapters-by-part | 10min | 30s | - |
|
||||
| chapter 正文 | 30min | - | - |
|
||||
| hot/recommended/stats | 5min | - | ✓ |
|
||||
| latest-chapters | 5min | - | ✓ |
|
||||
|
||||
**结论**:Redis + 内存 + 启动预热,冷启动已优化。
|
||||
|
||||
---
|
||||
|
||||
## 五、优化建议(按优先级)
|
||||
|
||||
### 高优先级
|
||||
|
||||
| 项 | 现状 | 建议 |
|
||||
|----|------|------|
|
||||
| index latest-chapters 重复 | loadFeatured + loadLatestChapters 各请求一次 | 合并为一次请求,两处共用数据 |
|
||||
|
||||
### 中优先级
|
||||
|
||||
| 项 | 现状 | 建议 |
|
||||
|----|------|------|
|
||||
| loadSuperMembers 串行 | vip/members 完成后再请求 users | 可并行发起,按需合并结果 |
|
||||
| chapters setStorage | 每次展开都同步写入 bookData | 节流或改为异步,避免大对象阻塞 |
|
||||
|
||||
### 低优先级
|
||||
|
||||
| 项 | 现状 | 说明 |
|
||||
|----|------|------|
|
||||
| read-extras | 阅读页 onLoad 即拉 | 可延到首次需要 linkTags 时再拉 |
|
||||
| GET 去重窗口 | 请求完成即删除 | 可考虑短时(如 200ms)窗口,覆盖快速重复点击 |
|
||||
|
||||
---
|
||||
|
||||
## 六、性能风险点
|
||||
|
||||
1. **Storage 同步写入**:`wx.setStorageSync` 在章节多时可能卡顿,建议监控或改为异步。
|
||||
2. **read 页 setData**:约 41 处 setData,单次更新数据量需控制,避免大对象。
|
||||
3. **长列表**:latestChapters 最多 20 条,当前规模可接受;若扩展需考虑虚拟列表。
|
||||
|
||||
---
|
||||
|
||||
## 七、总结
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 网络请求 | 良好 | 并行、去重、懒加载、缓存完善 |
|
||||
| 首屏速度 | 良好 | 请求少,后端有预热 |
|
||||
| 阅读体验 | 良好 | 单次拉取、复用、追踪合理 |
|
||||
| 存储与渲染 | 一般 | setStorage 同步、setData 次数可优化 |
|
||||
|
||||
**整体**:当前性能可接受,优先落地「index 合并 latest-chapters」即可带来明显收益。
|
||||
34
开发文档/废弃接口清单.md
Normal file
34
开发文档/废弃接口清单.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Soul 创业派对 - 废弃接口清单
|
||||
|
||||
> 以下接口已标记 Deprecated,保留以兼容旧版/管理端/next-project,计划后续下线。新开发请使用替代接口。
|
||||
|
||||
## 一、小程序组 `/api/miniprogram/*`
|
||||
|
||||
| 废弃接口 | 替代接口 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `GET /api/miniprogram/config` | `GET /config/core` + `GET /config/audit-mode` + `GET /config/read-extras` | 小程序已迁移至拆分接口 |
|
||||
| `GET /api/miniprogram/book/all-chapters` | `GET /book/parts` + `GET /book/chapters-by-part` + `GET /book/stats` | 小程序已迁移至按需加载 |
|
||||
|
||||
## 二、API 组 `/api/*`(next-project / 管理端)
|
||||
|
||||
| 废弃接口 | 替代接口 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `GET /api/book/all-chapters` | 同上 | 小程序已迁移至 miniprogram 组 |
|
||||
| `GET /api/db/config` | 小程序用 `/miniprogram/config/core` 等 | 公开配置,小程序已迁移 |
|
||||
|
||||
## 三、代码标记位置
|
||||
|
||||
| 文件 | 位置 |
|
||||
|------|------|
|
||||
| soul-api/internal/handler/db.go | `GetPublicDBConfig` 函数注释 |
|
||||
| soul-api/internal/handler/book.go | `BookAllChapters` 函数注释 |
|
||||
| soul-api/internal/router/router.go | miniprogram `/config`、`/book/all-chapters` 路由上方 |
|
||||
| soul-api/internal/router/router.go | api `/book/all-chapters`、`/db/config` 路由上方 |
|
||||
|
||||
## 四、迁移状态
|
||||
|
||||
| 端 | config | all-chapters |
|
||||
|----|--------|--------------|
|
||||
| 小程序 | ✅ 已迁移 core + audit-mode + read-extras | ✅ 已迁移 book/parts + chapters-by-part |
|
||||
| 管理端 | 使用 /api/db/config、config/full | 不直接调用 all-chapters |
|
||||
| next-project | 可能仍用 /api/db/config | 可能仍用 /api/book/all-chapters |
|
||||
124
开发文档/性能优化-综合分析.md
Normal file
124
开发文档/性能优化-综合分析.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Soul 创业派对 - 小程序与 API 性能优化综合分析
|
||||
|
||||
**分析时间**: 2026-03-18
|
||||
**范围**: 小程序(miniprogram)+ 后端 API(soul-api)
|
||||
|
||||
---
|
||||
|
||||
## 一、当前已完成的优化
|
||||
|
||||
### 1.1 API 端(soul-api)
|
||||
|
||||
| 优化项 | 说明 | 效果 |
|
||||
|--------|------|------|
|
||||
| **Redis 缓存** | config、book/parts、all-chapters、chapters-by-part、latest-chapters、free_chapters、chapter content、hot、recommended、stats | 命中时免 DB,响应 <50ms |
|
||||
| **启动预热** | WarmConfigCache、WarmAllChaptersCache、WarmBookPartsCache、WarmLatestChaptersCache | 首请求无冷启动 |
|
||||
| **Gzip 压缩** | gin-contrib/gzip 中间件 | 响应体缩小 60–80% |
|
||||
| **内存兜底** | parts、all-chapters、chapters-by-part 有 30s 内存缓存 | Redis 不可用时仍快 |
|
||||
|
||||
### 1.2 小程序端(miniprogram)
|
||||
|
||||
| 优化项 | 说明 | 效果 |
|
||||
|--------|------|------|
|
||||
| **骨架屏** | 阅读页、目录页、代付详情、搜索页 | 加载过程有占位,减少白屏 |
|
||||
| **bookData 本地缓存** | app.loadBookData 先读 wx.getStorageSync('bookData') | 二次打开可先展示缓存 |
|
||||
| **config 节流** | onShow 时 loadMpConfig 30 秒节流 | 避免频繁请求 |
|
||||
| **静默请求** | 统计、推荐访问等用 silent: true | 不阻塞 UI |
|
||||
|
||||
### 1.3 实测数据(正式环境 2026-03-18)
|
||||
|
||||
| 接口 | 平均(ms) | 最小(ms) | 最大(ms) |
|
||||
|------|----------|----------|----------|
|
||||
| config | 390 | 378 | 406 |
|
||||
| book/parts | 396 | 387 | 403 |
|
||||
| book/all-chapters | 390 | 376 | 407 |
|
||||
| book/chapters-by-part | 420 | 416 | 424 |
|
||||
| book/chapter/:id | 420 | 401 | 425 |
|
||||
| book/chapter/by-mid/:mid | 424 | 419 | 431 |
|
||||
|
||||
**说明**:约 390–430ms 主要为网络 RTT(用户→服务器),Redis 命中时服务端处理通常 <20ms。
|
||||
|
||||
---
|
||||
|
||||
## 二、待优化项(按优先级)
|
||||
|
||||
### 2.1 高优先级:减少重复请求
|
||||
|
||||
| 问题 | 现状 | 建议 |
|
||||
|------|------|------|
|
||||
| **config 多端重复** | app.loadMpConfig、index.loadFeatureConfig、read 页、TabBar 各自拉 config | 统一用 `app.globalData` 缓存,首次拉取后 5–10min 内复用;各页优先读缓存,过期再拉 |
|
||||
| **all-chapters 重复** | app.loadBookData + index.loadBookData 都调 all-chapters | index 优先用 `app.globalData.bookData`,仅当为空或需刷新时再请求 |
|
||||
| **loadFeaturedFromServer 串行** | 先 recommended → 失败再 hot → 再 latest-chapters | 可并行请求 recommended + latest-chapters,失败再兜底 |
|
||||
|
||||
### 2.2 中优先级:请求策略
|
||||
|
||||
| 问题 | 现状 | 建议 |
|
||||
|------|------|------|
|
||||
| **首页请求过多** | initData 触发 loadBookData、loadFeaturedFromServer、loadSuperMembers、loadLatestChapters 等 4+ 请求 | 合并或分阶段:首屏只拉 latestSection + featured 前 3;超级个体、最新新增可延迟加载 |
|
||||
| **阅读页 config** | 与 app.loadMpConfig 可能重复 | 优先用 `app.globalData` 中的 config,无缓存再请求 |
|
||||
| **request 无请求去重** | 同一接口并发多次会发多次请求 | 对相同 url 做短时(如 200ms)去重,避免重复请求 |
|
||||
|
||||
### 2.3 低优先级:体验与资源
|
||||
|
||||
| 问题 | 现状 | 建议 |
|
||||
|------|------|------|
|
||||
| **图片未优化** | 头像、海报等直接原图 | 使用 CDN 或后端缩略图;`<image>` 加 `lazy-load` |
|
||||
| **setData 频率** | 部分页面多次 setData 更新列表 | 合并更新,减少 setData 调用次数 |
|
||||
| **分包** | 未使用分包 | 非首屏页面(设置、代付、导师等)可放入分包,降低主包体积 |
|
||||
|
||||
---
|
||||
|
||||
## 三、API 端可进一步优化
|
||||
|
||||
| 方向 | 说明 |
|
||||
|------|------|
|
||||
| **HTTP/2** | 若部署支持,启用 HTTP/2 多路复用,减少连接开销 |
|
||||
| **Redis 连接池** | 检查 go-redis 默认 PoolSize,高并发时可适当调大 |
|
||||
| **book/search 缓存** | 搜索接口无缓存,热门关键词可加短 TTL(如 1–2min)缓存 |
|
||||
| **CDN 静态资源** | uploads 图片走 CDN,减轻 API 带宽压力 |
|
||||
|
||||
---
|
||||
|
||||
## 四、实施建议(分阶段)
|
||||
|
||||
### 阶段一:快速见效(1–2 天)
|
||||
|
||||
1. **config 统一缓存**:在 app.js 维护 `configCache` + 过期时间,各页优先读缓存。
|
||||
2. **index 复用 bookData**:`app.globalData.bookData` 有值时,index 不再单独请求 all-chapters。
|
||||
3. **loadFeaturedFromServer 并行**:`Promise.all([recommended, latest-chapters])` 并行请求。
|
||||
|
||||
### 阶段二:结构优化(3–5 天)
|
||||
|
||||
1. **首页分阶段加载**:首屏只拉 latestSection + featured;超级个体、最新新增 onShow 后延迟拉取。
|
||||
2. **request 去重**:对相同 url 在 200ms 内只发一次请求,后续调用复用同一 Promise。
|
||||
3. **图片 lazy-load**:列表、头像等加 `lazy-load`。
|
||||
|
||||
### 阶段三:长期(按需)
|
||||
|
||||
1. **分包加载**:非核心页面放入分包。
|
||||
2. **CDN**:静态资源迁移到 CDN。
|
||||
3. **监控**:接入性能监控,持续观察首屏、接口耗时。
|
||||
|
||||
---
|
||||
|
||||
## 五、关键代码位置索引
|
||||
|
||||
| 功能 | 文件路径 |
|
||||
|------|----------|
|
||||
| 请求封装 | miniprogram/app.js `request()` |
|
||||
| 启动加载 | miniprogram/app.js `onLaunch` → loadBookData、loadMpConfig |
|
||||
| 首页数据 | miniprogram/pages/index/index.js `initData`、loadFeaturedFromServer |
|
||||
| 阅读页加载 | miniprogram/pages/read/read.js `onLoad` |
|
||||
| API 缓存 | soul-api/internal/cache/cache.go |
|
||||
| 预热逻辑 | soul-api/cmd/server/main.go |
|
||||
|
||||
---
|
||||
|
||||
## 六、总结
|
||||
|
||||
- **已做**:Redis 多级缓存、启动预热、Gzip、骨架屏、本地缓存、节流等,接口平均 390–430ms,主要受网络 RTT 影响。
|
||||
- **优先做**:减少 config、all-chapters 等重复请求,统一缓存策略。
|
||||
- **中期**:首页分阶段加载、request 去重、图片懒加载。
|
||||
- **长期**:分包、CDN、性能监控。
|
||||
|
||||
按上述阶段推进,可在不大改架构的前提下,明显减少请求数和首屏等待时间。
|
||||
220
开发文档/服务器管理/Windows-WSL2-QEMU-macOS虚拟机安装方案.md
Normal file
220
开发文档/服务器管理/Windows-WSL2-QEMU-macOS虚拟机安装方案.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Windows 上通过 WSL2 + QEMU/KVM 运行 macOS 虚拟机(安装方案)
|
||||
|
||||
> 目标:在 **Windows 10/11** 上,以 **WSL2(Ubuntu)+ QEMU/KVM + OpenCore 引导**的方式运行 macOS 虚拟机,用于演示/测试。
|
||||
>
|
||||
> 重要说明:
|
||||
> - **macOS 不能用 Docker 容器运行**(Docker 只运行 Linux 容器)。本方案使用的是 **虚拟机**。
|
||||
> - 本方案 **不依赖 Docker Desktop**,也不会要求你卸载/改动 Docker Desktop。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- **Windows 10/11**(建议较新的版本)
|
||||
- CPU 支持虚拟化(Intel VT-x / AMD-V),并在 BIOS/UEFI 中开启
|
||||
- 管理员权限(启用 Windows 功能、安装 WSL、创建防火墙规则时可能需要)
|
||||
- 具备稳定网络(下载恢复镜像与依赖)
|
||||
- 一个 VNC 客户端(例如 TightVNC/RealVNC/TigerVNC 等)
|
||||
|
||||
---
|
||||
|
||||
## 2. 启用 WSL2(管理员 PowerShell)
|
||||
|
||||
> 如果你已经能 `wsl -l -v` 正常看到发行版,可跳过本节。
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
重启后,如需安装指定发行版:
|
||||
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04 --web-download
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```powershell
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
期望看到 `Ubuntu-24.04` 且 `VERSION` 为 `2`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录约定(建议)
|
||||
|
||||
建议把虚拟机相关文件统一放到一个目录,便于迁移与清理,例如:
|
||||
|
||||
- Windows:`%USERPROFILE%\Mycontent\macos-vm`
|
||||
- WSL:`/mnt/c/Users/<你的用户名>/Mycontent/macos-vm`
|
||||
|
||||
在 Windows 侧先创建目录:
|
||||
|
||||
```powershell
|
||||
mkdir "$env:USERPROFILE\Mycontent\macos-vm" -Force | Out-Null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 在 Ubuntu 中安装 QEMU / KVM 依赖
|
||||
|
||||
进入 Ubuntu:
|
||||
|
||||
```powershell
|
||||
wsl -d Ubuntu-24.04
|
||||
```
|
||||
|
||||
在 Ubuntu 里执行:
|
||||
|
||||
```bash
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker git curl unzip
|
||||
```
|
||||
|
||||
验证 KVM:
|
||||
|
||||
```bash
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
期望输出包含:
|
||||
|
||||
- `INFO: /dev/kvm exists`
|
||||
- `KVM acceleration can be used`
|
||||
|
||||
若没有 `/dev/kvm`,通常是 BIOS 虚拟化未开、或系统虚拟化相关功能未启用导致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 获取启动脚本工程(推荐 ZIP 方式)
|
||||
|
||||
下面以 OneClick-macOS-Simple-KVM 为例(它主要负责:下载恢复镜像、准备虚拟盘、拼 QEMU 启动参数)。
|
||||
|
||||
在 Ubuntu 中进入工作目录:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/<你的用户名>/Mycontent/macos-vm
|
||||
```
|
||||
|
||||
下载并解压(ZIP 方式更抗网络波动):
|
||||
|
||||
```bash
|
||||
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
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
chmod +x *.sh *.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 准备虚拟硬盘与恢复镜像(以 Ventura 为例)
|
||||
|
||||
创建系统盘(容量可按需调整):
|
||||
|
||||
```bash
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
```
|
||||
|
||||
下载恢复镜像(脚本会拉取官方 Recovery/Restore 资源并生成/落盘):
|
||||
|
||||
```bash
|
||||
python3 fetch-macOS-v2.py -s ventura
|
||||
```
|
||||
|
||||
常见做法是把下载到的 dmg 统一命名为 `BaseSystem.dmg`,并转 raw:
|
||||
|
||||
```bash
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
ls -lah BaseSystem.* macOS.qcow2
|
||||
```
|
||||
|
||||
经验上你会看到:
|
||||
|
||||
- `BaseSystem.dmg` 数百 MB
|
||||
- `BaseSystem.img` 数 GB
|
||||
- `macOS.qcow2` 初始很小,安装完成后会逐渐变大
|
||||
|
||||
---
|
||||
|
||||
## 7. 启动虚拟机(Headless + VNC)
|
||||
|
||||
在 OneClick 目录启动(Headless 表示不弹 GUI 窗口,改走 VNC):
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
在 Windows 侧确认 VNC 端口监听(通常是 `5900`):
|
||||
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 5900 -State Listen
|
||||
```
|
||||
|
||||
然后用 VNC 客户端连接:
|
||||
|
||||
- `localhost:5900`
|
||||
|
||||
进入图形界面后,常规安装流程是:
|
||||
|
||||
- 先打开“磁盘工具”抹掉/格式化虚拟盘(APFS)
|
||||
- 退出磁盘工具
|
||||
- 选择“安装 macOS”
|
||||
|
||||
安装过程中会多次重启属正常。
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### 8.1 `kvm-ok` 不通过 / 没有 `/dev/kvm`
|
||||
|
||||
- 检查 BIOS/UEFI 是否开启虚拟化
|
||||
- 确保启用了 Windows 虚拟化相关功能(WSL2、VirtualMachinePlatform 等)
|
||||
- 若你已经在用 WSL2,但仍没有 `/dev/kvm`,通常是环境限制或虚拟化被占用/策略限制
|
||||
|
||||
### 8.2 VNC 连不上
|
||||
|
||||
- Windows 侧确认 `5900` 端口确实在 Listen
|
||||
- 检查防火墙策略(本机连 `localhost` 一般不需要额外放行)
|
||||
- 确认启动脚本没有退出(WSL 窗口关闭会导致 VM 退出)
|
||||
|
||||
---
|
||||
|
||||
## 9. 卸载/清理(不动 Docker Desktop)
|
||||
|
||||
### 9.1 只删除 macOS VM 文件(保留 Ubuntu)
|
||||
|
||||
直接删目录即可(示例路径):
|
||||
|
||||
```powershell
|
||||
Remove-Item -Recurse -Force "$env:USERPROFILE\Mycontent\macos-vm"
|
||||
```
|
||||
|
||||
### 9.2 卸载 Ubuntu(保留 Docker Desktop)
|
||||
|
||||
先确认 WSL 中有哪些发行版:
|
||||
|
||||
```powershell
|
||||
wsl -l -v
|
||||
```
|
||||
|
||||
只卸载 Ubuntu(示例 `Ubuntu-24.04`):
|
||||
|
||||
```powershell
|
||||
wsl --terminate Ubuntu-24.04
|
||||
wsl --unregister Ubuntu-24.04
|
||||
```
|
||||
|
||||
> 注意:以上会删除 Ubuntu 发行版的全部数据,但不会卸载 Docker Desktop,也不会动 `docker-desktop` 发行版。
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# Windows 上运行 macOS 虚拟机 - GitHub 方案汇总
|
||||
|
||||
> **重要说明**:**macOS 无法在 Docker 中运行**。Docker 只能运行 Linux 容器。本方案使用 **WSL2 + QEMU/KVM 虚拟机**,在 Windows 上虚拟出一台「Mac」。
|
||||
>
|
||||
> **用途**:仅用于演示、测试、学习。请遵守 Apple 软件许可协议。
|
||||
>
|
||||
> **环境**:Windows 10/11 + WSL2 + Ubuntu,通过 QEMU/KVM 运行 macOS 虚拟机。
|
||||
|
||||
---
|
||||
|
||||
## 一、GitHub 方案概览
|
||||
|
||||
| 项目 | Stars | 适用场景 | Windows 支持 |
|
||||
|------|-------|----------|-------------|
|
||||
| [OneClick-macOS-Simple-KVM](https://github.com/notAperson535/OneClick-macOS-Simple-KVM) | 877 | **推荐** 一键安装,适合 Windows | ✅ 支持 |
|
||||
| [OSX-KVM](https://github.com/kholia/OSX-KVM) | 23k | 成熟方案,支持 Monterey/Ventura/Sonoma | ⚠️ 需 WSL2 |
|
||||
| [dortania/OpenCore-Install-Guide](https://github.com/dortania/OpenCore-Install-Guide) | 4.1k | 官方 OpenCore 安装指南 | 通用 |
|
||||
| [Elymac/Hackintosh](https://github.com/Elymac/Hackintosh) | 3 | Windows 下制作 macOS 安装盘 | 物理机安装 |
|
||||
|
||||
**结论**:在 Windows 上做演示/测试,优先用 **OneClick-macOS-Simple-KVM**(WSL2 + QEMU/KVM)。
|
||||
|
||||
---
|
||||
|
||||
## 二、前置要求
|
||||
|
||||
- Windows 10 或更高
|
||||
- 8GB+ 内存(建议 16GB)
|
||||
- CPU 支持虚拟化(BIOS 中开启 VT-x/AMD-V)
|
||||
- **AMD Ryzen/EPYC**:需 Windows 11 才支持嵌套虚拟化
|
||||
|
||||
---
|
||||
|
||||
## 三、全自动安装步骤(PowerShell + WSL2)
|
||||
|
||||
> 也可使用项目提供的脚本:`开发文档/服务器管理/scripts/macOS-VM-安装-步骤1-WSL.ps1` 和 `macOS-VM-安装-步骤2-克隆与检查.ps1`(步骤 1 需管理员权限)。
|
||||
|
||||
### 步骤 1:安装 WSL2(管理员 PowerShell)
|
||||
|
||||
```powershell
|
||||
wsl --install
|
||||
```
|
||||
|
||||
安装完成后**重启电脑**。若未自动安装 Ubuntu,再执行:
|
||||
|
||||
```powershell
|
||||
wsl --install Ubuntu
|
||||
```
|
||||
|
||||
### 步骤 2:进入 WSL 并克隆项目
|
||||
|
||||
重启后打开 **Ubuntu** 终端,执行(将 `WINDOWS_USER_NAME` 换成你的 Windows 用户名):
|
||||
|
||||
```bash
|
||||
cd /mnt/c/users/WINDOWS_USER_NAME/Documents
|
||||
sudo apt update && sudo apt install -y git cpu-checker
|
||||
git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
```
|
||||
|
||||
### 步骤 3:检查虚拟化
|
||||
|
||||
```bash
|
||||
kvm-ok
|
||||
```
|
||||
|
||||
应输出 `KVM acceleration can be used`。
|
||||
|
||||
- **Intel**:`cat /sys/module/kvm_intel/parameters/nested` 应为 `Y`
|
||||
- **AMD**:`cat /sys/module/kvm_amd/parameters/nested` 应为 `Y` 或 `1`
|
||||
|
||||
若为 `N`,见文末「故障排查」。
|
||||
|
||||
### 步骤 4:首次设置并启动
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
出现 QEMU 窗口后按 `Ctrl+C` 停止,再执行:
|
||||
|
||||
```bash
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
### 步骤 5:用 VNC 连接
|
||||
|
||||
1. 下载 [VNC Viewer](https://www.realvnc.com/en/connect/download/viewer/)
|
||||
2. 连接地址:`localhost:5900`
|
||||
3. 在虚拟机内完成 macOS 安装向导
|
||||
|
||||
---
|
||||
|
||||
## 四、日常启动(非首次)
|
||||
|
||||
每次启动虚拟机只需:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/users/WINDOWS_USER_NAME/Documents/OneClick-macOS-Simple-KVM
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
```
|
||||
|
||||
然后用 VNC 连接 `localhost:5900`。
|
||||
|
||||
**注意**:不要关闭运行 `basic.sh` 的终端,否则虚拟机会关闭。
|
||||
|
||||
---
|
||||
|
||||
## 五、故障排查
|
||||
|
||||
### 1. nested 返回 N,但 kvm-ok 正常
|
||||
|
||||
在 Windows 用户目录创建/编辑 `.wslconfig`(路径:`C:\Users\WINDOWS_USER_NAME\.wslconfig`):
|
||||
|
||||
```ini
|
||||
[wsl2]
|
||||
nestedVirtualization=true
|
||||
kernel=C:\\Users\\WINDOWS_USER_NAME\\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`,再重新打开 WSL。
|
||||
|
||||
### 2. AMD 处理器提示 "Your CPU does not support KVM extensions"
|
||||
|
||||
Windows 10 对 AMD Ryzen/EPYC 的嵌套虚拟化支持有限,需升级到 **Windows 11**。
|
||||
|
||||
### 3. 获取 Windows 用户名
|
||||
|
||||
PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
$env:USERNAME
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、相关链接
|
||||
|
||||
- [OneClick 官方文档 - Windows 安装](https://oneclick-macos-simple-kvm.notaperson535.is-a.dev/docs/windows-install)
|
||||
- [Dortania OpenCore 安装指南](https://dortania.github.io/OpenCore-Install-Guide/)
|
||||
- [OSX-KVM 项目](https://github.com/kholia/OSX-KVM)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 七、一键脚本
|
||||
|
||||
- **macOS-VM-安装-简化版.bat**:双击运行,自动完成克隆、安装依赖、执行 setup
|
||||
- 若 setup.sh 提示选择 macOS 版本,输入 `7` 选 Sonoma 或 `6` 选 Ventura
|
||||
- 出现 QEMU 窗口后按 Ctrl+C,再执行 `sudo HEADLESS=1 ./basic.sh`,用 VNC 连接 localhost:5900
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间:2026-03-09*
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
lobster_macos_vm.py
|
||||
|
||||
“龙虾” 一键脚本:在 Windows 10/11 上,通过 WSL2 + Ubuntu-24.04 + QEMU/KVM
|
||||
和 OneClick-macOS-Simple-KVM 自动准备 macOS 虚拟机(Ventura)。
|
||||
|
||||
使用方式(在 Windows PowerShell 中):
|
||||
|
||||
python C:\\Users\\<USERNAME>\\Mycontent\\macos-vm\\lobster_macos_vm.py
|
||||
|
||||
注意:
|
||||
- 需要管理员权限安装 WSL2(如果此前未安装)。
|
||||
- 脚本不会替你点 macOS 图形安装向导,只会把虚拟机和 VNC 端口拉起来。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(cmd, check=True):
|
||||
"""在 Windows Shell 中运行命令,并在失败时抛异常。"""
|
||||
print(f"[lobster] RUN: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True)
|
||||
if check and result.returncode != 0:
|
||||
raise RuntimeError(f"命令执行失败(exit {result.returncode}):{cmd}")
|
||||
return result.returncode
|
||||
|
||||
|
||||
def ensure_wsl_installed():
|
||||
"""检测 WSL 是否可用,不负责安装(安装通常需要手工 + 重启)。"""
|
||||
try:
|
||||
subprocess.run("wsl -l -v", shell=True, check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
print(
|
||||
"[lobster] 检测到 WSL 尚未可用,请以管理员权限运行:wsl --install,然后重启电脑后再运行本脚本。"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_ubuntu_distro():
|
||||
"""确保存在 Ubuntu-24.04 发行版(不强制版本号,只要有 Ubuntu 即可继续)。"""
|
||||
proc = subprocess.run(
|
||||
"wsl -l -v", shell=True, capture_output=True, text=True, check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print(proc.stdout)
|
||||
print(proc.stderr)
|
||||
raise RuntimeError("wsl -l -v 执行失败,无法检测发行版。")
|
||||
|
||||
lines = proc.stdout.splitlines()
|
||||
ubuntu_exists = any("Ubuntu" in line for line in lines)
|
||||
if not ubuntu_exists:
|
||||
print(
|
||||
"[lobster] 未发现 Ubuntu 发行版,请以管理员权限运行:\n"
|
||||
" wsl --install -d Ubuntu-24.04 --web-download\n"
|
||||
"安装完成、重启并完成 Ubuntu 初始化后,再运行本脚本。"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def wsl_path(win_path: Path) -> str:
|
||||
"""将 Windows 路径转换为 WSL 路径(简单 C: -> /mnt/c 映射)。"""
|
||||
drive = win_path.drive.replace(":", "").lower()
|
||||
rel = win_path.as_posix().split(":/")[-1] if ":/" in win_path.as_posix() else win_path.as_posix().split(":")[-1]
|
||||
# 去掉开头的 /
|
||||
if rel.startswith("/"):
|
||||
rel = rel[1:]
|
||||
return f"/mnt/{drive}/{rel.replace(' ', '\\ ')}"
|
||||
|
||||
|
||||
def run_in_wsl(bash_script: str):
|
||||
"""把一段 bash 脚本送进 WSL 执行。"""
|
||||
cmd = f"wsl -e bash -lc \"set -e; {bash_script}\""
|
||||
return run(cmd)
|
||||
|
||||
|
||||
def main():
|
||||
username = os.environ.get("USERNAME") or os.environ.get("USER")
|
||||
if not username:
|
||||
raise RuntimeError("无法获取当前 Windows 用户名(USER/USERNAME)。")
|
||||
|
||||
# 1. 检查 WSL / Ubuntu
|
||||
print("[lobster] 第 1 步:检查 WSL / Ubuntu 环境...")
|
||||
ensure_wsl_installed()
|
||||
ensure_ubuntu_distro()
|
||||
|
||||
# 2. 创建固定目录 C:\Users\<USERNAME>\Mycontent\macos-vm
|
||||
base_dir = Path(f"C:/Users/{username}/Mycontent/macos-vm")
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"[lobster] 使用目录:{base_dir}")
|
||||
|
||||
wsl_base = wsl_path(base_dir)
|
||||
|
||||
# 3. 在 WSL 中下载 OneClick 源码 zip 并解压
|
||||
print("[lobster] 第 2 步:下载 OneClick 源码 zip 并解压...")
|
||||
bash_download = f"""
|
||||
cd {wsl_base}
|
||||
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 900 \\
|
||||
-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
|
||||
"""
|
||||
run_in_wsl(bash_download)
|
||||
|
||||
# 4. 安装 QEMU / Python / cpu-checker,并检查 KVM
|
||||
print("[lobster] 第 3 步:安装 QEMU / Python / cpu-checker,并检查 KVM...")
|
||||
bash_deps = f"""
|
||||
cd {wsl_base}/OneClick-macOS-Simple-KVM
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker
|
||||
kvm-ok || true
|
||||
"""
|
||||
run_in_wsl(bash_deps)
|
||||
|
||||
# 5. 下载 macOS Ventura 恢复镜像并生成 BaseSystem.img / macOS.qcow2
|
||||
print("[lobster] 第 4 步:下载 macOS Ventura 恢复镜像并生成 BaseSystem.img...")
|
||||
bash_fetch = f"""
|
||||
cd {wsl_base}/OneClick-macOS-Simple-KVM
|
||||
chmod +x *.sh *.py || true
|
||||
[ -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 || true
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
ls -lah BaseSystem.* macOS.qcow2
|
||||
"""
|
||||
run_in_wsl(bash_fetch)
|
||||
|
||||
# 6. 启动虚拟机(HEADLESS + VNC 5900)
|
||||
print("[lobster] 第 5 步:启动 macOS 虚拟机(HEADLESS + VNC: localhost:5900)...")
|
||||
bash_start = f"""
|
||||
cd {wsl_base}/OneClick-macOS-Simple-KVM
|
||||
sudo HEADLESS=1 ./basic.sh
|
||||
"""
|
||||
# 不阻塞当前 PowerShell:用 wslrelay/wsl 后台端口监听,由用户自己关
|
||||
subprocess.Popen(
|
||||
f"wsl -e bash -lc \"{bash_start}\"",
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
print("\n[lobster] 已尝试启动 macOS 虚拟机。")
|
||||
print("请确保已经安装 VNC Viewer(如 RealVNC)。")
|
||||
print("在 VNC Viewer 中连接:localhost:5900")
|
||||
print("随后在图形界面中完成磁盘抹盘和 macOS 安装向导即可。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[lobster] 收到中断,已停止。")
|
||||
except Exception as e:
|
||||
print(f"\n[lobster] 发生错误:{e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 全自动 macOS 虚拟机安装脚本 - 无交互
|
||||
# 在 WSL Ubuntu 中运行
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 安装依赖 ==="
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip
|
||||
|
||||
echo "=== 创建虚拟磁盘 ==="
|
||||
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
|
||||
echo "=== 下载 macOS Ventura 恢复镜像(约 6GB,请耐心等待)==="
|
||||
python3 fetch-macOS-v2.py -s ventura 2>/dev/null || python3 fetch-macOS-v2.py -s catalina
|
||||
|
||||
echo "=== 转换镜像 ==="
|
||||
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
|
||||
echo "=== 完成。启动虚拟机请执行: sudo HEADLESS=1 ./basic.sh ==="
|
||||
echo "=== 然后用 VNC Viewer 连接 localhost:5900 ==="
|
||||
@@ -1,51 +0,0 @@
|
||||
# macOS 虚拟机 - 一键安装脚本(全自动,无交互)
|
||||
# 说明:macOS 无法在 Docker 中运行,本脚本使用 WSL2 + QEMU/KVM 虚拟机方案
|
||||
# 运行方式:PowerShell 中执行 .\macOS-VM-一键安装.ps1
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$docPath = "C:\Users\$env:USERNAME\Documents"
|
||||
$repoPath = "$docPath\OneClick-macOS-Simple-KVM"
|
||||
$wslPath = "/mnt/c/Users/$env:USERNAME/Documents/OneClick-macOS-Simple-KVM" -replace '\\','/'
|
||||
|
||||
Write-Host "`n=== macOS 虚拟机一键安装(全自动)===" -ForegroundColor Cyan
|
||||
Write-Host "目标: $repoPath`n" -ForegroundColor Gray
|
||||
|
||||
# 1. 克隆项目
|
||||
if (-not (Test-Path "$repoPath\.git")) {
|
||||
Write-Host "[1/5] 克隆 OneClick-macOS-Simple-KVM..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/$env:USERNAME/Documents; git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git 2>&1"
|
||||
} else {
|
||||
Write-Host "[1/5] 项目已存在" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 2. 安装依赖
|
||||
Write-Host "`n[2/5] 安装 QEMU、Python 等依赖..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd $wslPath; export DEBIAN_FRONTEND=noninteractive; sudo apt-get update -qq; sudo apt-get install -y qemu-system qemu-utils python3 python3-pip 2>&1 | tail -5"
|
||||
|
||||
# 3. 检查 KVM
|
||||
Write-Host "`n[3/5] 检查虚拟化..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash -c "kvm-ok 2>&1"
|
||||
|
||||
# 4. 生成并执行 bash 脚本
|
||||
$shContent = @'
|
||||
cd PLACEHOLDER_WSLPATH
|
||||
chmod +x *.sh *.py 2>/dev/null
|
||||
test -f macOS.qcow2 || qemu-img create -f qcow2 macOS.qcow2 64G
|
||||
python3 fetch-macOS-v2.py -s ventura 2>/dev/null || python3 fetch-macOS-v2.py -s catalina
|
||||
test -f RecoveryImage.dmg && mv RecoveryImage.dmg BaseSystem.dmg
|
||||
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
|
||||
echo done
|
||||
'@ -replace 'PLACEHOLDER_WSLPATH', $wslPath
|
||||
$shFile = Join-Path $env:TEMP "macos-vm-setup-$PID.sh"
|
||||
$shContent | Out-File -FilePath $shFile -Encoding ASCII
|
||||
$wslShPath = "/mnt/" + $shFile.Substring(0,1).ToLower() + ($shFile.Substring(2) -replace '\\','/')
|
||||
|
||||
Write-Host "`n[4/5] 下载 macOS Ventura 恢复镜像(约 6GB,需 10-30 分钟)..." -ForegroundColor Yellow
|
||||
wsl -d Ubuntu-24.04 -e bash $wslShPath
|
||||
Remove-Item $shFile -ErrorAction SilentlyContinue
|
||||
|
||||
# 5. 启动虚拟机
|
||||
Write-Host "`n[5/5] 启动 macOS 虚拟机..." -ForegroundColor Yellow
|
||||
Write-Host "VNC: localhost:5900" -ForegroundColor Cyan
|
||||
$vmCmd = 'cd ' + $wslPath + '; sudo HEADLESS=1 ./basic.sh'
|
||||
wsl -d Ubuntu-24.04 -e bash -c $vmCmd
|
||||
@@ -1,13 +0,0 @@
|
||||
# macOS 虚拟机安装 - 步骤 1:安装 WSL2
|
||||
# 需以管理员身份运行 PowerShell
|
||||
# 执行后需重启电脑,再运行 步骤2
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=== macOS 虚拟机安装 - 步骤 1/2 ===" -ForegroundColor Cyan
|
||||
Write-Host "正在安装 WSL2..." -ForegroundColor Yellow
|
||||
|
||||
wsl --install
|
||||
|
||||
Write-Host "`nWSL 安装完成。请重启电脑,然后运行 步骤2 脚本。" -ForegroundColor Green
|
||||
Write-Host "若未自动安装 Ubuntu,重启后可执行: wsl --install Ubuntu" -ForegroundColor Gray
|
||||
@@ -1,44 +0,0 @@
|
||||
# macOS 虚拟机安装 - 步骤 2:克隆项目并检查环境
|
||||
# 在重启后运行,会启动 WSL 并执行克隆与检查
|
||||
# 需先完成步骤 1 并重启
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$winUser = $env:USERNAME
|
||||
$targetDir = "C:\Users\$winUser\Documents\OneClick-macOS-Simple-KVM"
|
||||
|
||||
Write-Host "=== macOS 虚拟机安装 - 步骤 2/2 ===" -ForegroundColor Cyan
|
||||
Write-Host "Windows 用户: $winUser" -ForegroundColor Gray
|
||||
Write-Host "目标目录: $targetDir`n" -ForegroundColor Gray
|
||||
|
||||
# 生成临时脚本并执行
|
||||
$shPath = "$env:TEMP\macos-vm-setup.sh"
|
||||
$shContent = @"
|
||||
cd /mnt/c/users/$winUser/Documents
|
||||
sudo apt update -qq && sudo apt install -y git cpu-checker 2>/dev/null
|
||||
if [ -d OneClick-macOS-Simple-KVM ]; then
|
||||
echo '项目已存在,进入目录...'
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
else
|
||||
git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
|
||||
cd OneClick-macOS-Simple-KVM
|
||||
fi
|
||||
echo ''
|
||||
echo '=== 虚拟化检查 ==='
|
||||
kvm-ok 2>/dev/null || echo 'kvm-ok 未通过,请检查 BIOS 虚拟化设置'
|
||||
echo ''
|
||||
echo '=== 下一步 ==='
|
||||
echo '在 WSL Ubuntu 终端中执行:'
|
||||
echo ' cd /mnt/c/users/$winUser/Documents/OneClick-macOS-Simple-KVM'
|
||||
echo ' ./setup.sh'
|
||||
echo ''
|
||||
echo '出现 QEMU 窗口后按 Ctrl+C,再执行:'
|
||||
echo ' sudo HEADLESS=1 ./basic.sh'
|
||||
echo ''
|
||||
echo '然后下载 VNC Viewer,连接 localhost:5900'
|
||||
"@
|
||||
$shContent | Out-File -FilePath $shPath -Encoding ASCII
|
||||
$wslPath = '/mnt/' + $shPath.Substring(0,1).ToLower() + $shPath.Substring(2) -replace '\\', '/'
|
||||
wsl bash $wslPath
|
||||
Remove-Item $shPath -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "`n完成。请按上述提示在 WSL 中继续操作。" -ForegroundColor Green
|
||||
@@ -1,27 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo === macOS 虚拟机安装(WSL2+QEMU,非 Docker)===
|
||||
echo.
|
||||
echo [1] 克隆项目...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents && (test -d OneClick-macOS-Simple-KVM && echo already exists || git clone https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git) 2>&1"
|
||||
if not exist "C:\Users\29195\Documents\OneClick-macOS-Simple-KVM\.git" (
|
||||
echo 克隆失败,请检查网络
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2] 安装依赖...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents/OneClick-macOS-Simple-KVM && sudo apt-get update -qq && sudo apt-get install -y qemu-system qemu-utils python3 python3-pip"
|
||||
|
||||
echo.
|
||||
echo [3] 检查 KVM...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "kvm-ok"
|
||||
|
||||
echo.
|
||||
echo [4] 下载 macOS 并配置(约 6GB,需 10-30 分钟)...
|
||||
wsl -d Ubuntu-24.04 -e bash -c "cd /mnt/c/Users/29195/Documents/OneClick-macOS-Simple-KVM && chmod +x *.sh *.py && ./setup.sh"
|
||||
|
||||
echo.
|
||||
echo 完成后请用 VNC Viewer 连接 localhost:5900
|
||||
pause
|
||||
Reference in New Issue
Block a user