重构小程序图标组件,替换传统 emoji 为 SVG 图标,提升视觉一致性和可维护性。更新多个页面以使用新图标组件,优化用户界面体验。同时,调整了数据加载逻辑,确保更高效的状态管理和用户交互。

This commit is contained in:
Alex-larget
2026-03-18 16:00:57 +08:00
parent 46f94a9c81
commit c55e54efbd
62 changed files with 2033 additions and 1270 deletions

View File

@@ -1,101 +1,5 @@
# 团队共享 经验记录 - 2026-03-10
## 在 Windows 上一键启动 macOS 虚拟机(“龙虾”智能体经验)
### 场景 / 问题
- 成员希望在 **Windows 10/11** 上通过 **Docker** 一键安装 macOS用于演示 / 测试。
- 实际上:
- **macOS 不能在 Docker 中运行**Docker 只跑 Linux 容器,没有合法的 macOS 镜像)。
- 唯一可行路径是:**WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM**。
### 关键决策
1. **明确技术与法律边界**
- 不支持也不承诺在 Docker 中直接跑 macOS。
- 统一采用「**WSL2 + QEMU/KVM + OneClick**」这个方案,仅用于演示 / 测试。
2. **固定目录与流程约定**
- Windows 侧统一放在:`C:\Users\{USERNAME}\Mycontent\macos-vm`
- WSL 侧路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
- 内部结构:
- `OneClick-macOS-Simple-KVM/`
- `BaseSystem.dmg` / `BaseSystem.img`
- `macOS.qcow2`
3. **获取 OneClick 源码时优先走 zip而不是 git clone**
- 直接 `git clone` 很容易在国内网络环境下触发 `GnuTLS recv error (-110)` 等 TLS 超时。
- 统一约定使用:
- `https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master`
- 然后在 WSL 中:
- `curl + unzip` → 解压 → 重命名为 `OneClick-macOS-Simple-KVM`
4. **依赖安装与 KVM 检查**
- 在 Ubuntu-24.04 内安装:
- `qemu-system qemu-utils python3 python3-pip cpu-checker`
- 使用 `kvm-ok` 检查:
- 期望输出:`/dev/kvm exists` + `KVM acceleration can be used`
-`nested``N` 且需要嵌套虚拟化,使用 `.wslconfig` 打开 nested。
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
- 通过 `python3 fetch-macOS-v2.py -s ventura` 下载官方 Recovery 镜像。
-`RecoveryImage.dmg` 重命名为 `BaseSystem.dmg`,再用 `qemu-img convert` 生成 `BaseSystem.img`
- 验收标准:
- `BaseSystem.dmg` ≈ 678 MB
- `BaseSystem.img` ≈ 3.0 GB
6. **以 HEADLESS + VNC 方式启动 VM**
- 使用 `sudo HEADLESS=1 ./basic.sh` 启动 QEMU。
- 在 Windows 中确认 `127.0.0.1:5900` 端口监听。
- 统一告知使用 VNC 客户端连接 `localhost:5900`,再在图形界面内完成 macOS 安装向导。**用户环境已采用 TightVNC**,与 RealVNC Viewer 等方式等效。
7. **WSL 卡死与多 wsl 进程清理策略**
-`wsl --shutdown` 卡住、或者有大量 `wsl` 进程残留时:
- 使用 PowerShell `Get-Process wsl | Stop-Process -Force` 清理。
- 再执行 `wsl --shutdown` + `wsl -l -v` 验证状态恢复。
### 对应 Skill / 智能体
- 新建 Skill`.cursor/skills/lobster-macos-vm/SKILL.md`
- 技能名:`lobster-macos-vm`
- 智能体名(对外):**“龙虾”**
- 职责:当用户在 Windows 上提出安装 / 维护 macOS 虚拟机的需求时,统一按该 Skill 流程执行:
- 解释 Docker 不可用 → 切换到 WSL2 + QEMU 方案。
- 固定目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → HEADLESS 启动 → 引导 VNC 安装。
- 计划脚本化:
-`开发文档/服务器管理/scripts/lobster_macos_vm.py` 中实现一键部署脚本,封装上述流程,供“龙虾”及人类成员复用。
### 用户环境补充
- **VNC 客户端**:当前环境使用 **TightVNC** 连接 `localhost:5900`,已写入龙虾 Skill后续回复可一并推荐 TightVNC / RealVNC / TigerVNC 等。
### 安装完成与使用规范快照
- 当前虚拟机参数:
- 内存8G`-m 8G`
- CPU1 颗 CPU × 4 核 × 2 线程(共 8 线程)
- 系统盘:`macOS.qcow2`,逻辑容量 64G可后续通过 `qemu-img resize` 扩容。
- 启动方式快照:
- 优先使用 `C:\Users\{USERNAME}\Mycontent\macos-vm\一键启动-macOS虚拟机.bat`
- bat 内部做两件事:
1. 启动 WSL → 进入 `OneClick-macOS-Simple-KVM``sudo HEADLESS=1 ./basic.sh`
2. 等待约 10 秒后自动启动 TightVNC Viewer 连接 `localhost:5900`
- 关闭规则不要关名为「macOS 虚拟机 - 勿关此窗口」的终端窗口,否则虚拟机会被强制关闭。
- 安装阶段经验:
- 安装过程中多次重启属正常,出现 `X86PlatformPlugin::systemWillShutdown!` / `IOPlatformHaltRestartAction -> AppleSMC` 说明在正常关机/重启。
- OpenCore 菜单的选择顺序:
- 安装阶段:多次选择 `macOS Installer` 直至不再出现安装向导。
- 安装完成:只选系统盘(如 `Macintosh HD`),不要选 `mac - Data`
- 安装完成后,为避免反复从恢复盘启动,`basic.sh` 默认不再挂载 `BaseSystem.img`;需要重装时可通过 `INSTALL_MEDIA=1 HEADLESS=1 ./basic.sh` 临时挂载。
### 适用角色
- 后端 / 运维:需要在本地或服务器上快速拉起 macOS VM 做兼容性验证或演示。
- 团队:对外说明 **“我们不支持 Docker macOS统一用龙虾方案”**。
---
## 管理端迁移 Mycontent-temp菜单/布局新规范基线
### 决议(团队共享)

View File

@@ -25,7 +25,6 @@
| 2026-02-27 | 小程序、团队 | 最佳实践 | SKILL-小程序开发 §6、SKILL-管理端开发 §4.1 | 输入框 padding 用 view/div 包裹 |
| 2026-02-28 | 小程序、管理端 | 最佳实践 | miniprogram §6、admin §4.1 | input 边距口诀「外边包 view、内部 width 100%」match 弹窗已修正 |
| 2026-03-03 | 小程序 | 最佳实践 | miniprogram §8 | 我的页面卡片区边距 16rpx个人中心类页面布局规范 |
| 2026-03-10 | 团队 | 架构/运维约定 | lobster-macos-vm Skill | Windows 上统一使用 WSL2+QEMU+OneClick 的"龙虾"方案安装 macOS 虚拟机,禁止 Docker 直跑 macOS |
| 2026-03-10 | 小程序 | 最佳实践 | miniprogram-dev SKILL §my | my.js 阅读统计改为后端接口loadDashboardStats禁止用随机数时间/标题占位 |
| 2026-03-10 | 后端 | bug 修复 | api-dev SKILL | 聚合接口三处修复recentChapters 去重、totalReadMinutes 最小1分钟、DB 错误返回 500 |
| 2026-03-10 | 团队 | 方法论 | - | 新旧版代码对比:以功能完整性为基准,批量 diff + 分类取舍,不以日期判优劣 |

View File

@@ -1,184 +0,0 @@
---
name: lobster-macos-vm
description: Automates provisioning and troubleshooting of macOS virtual machines on Windows using WSL2, QEMU/KVM, and the OneClick-macOS-Simple-KVM project. Use when the user mentions 龙虾, macOS 虚拟机, 一键安装苹果系统 on Windows, or needs to re-deploy the Ventura/Sonoma VM.
---
# 龙虾lobster-macos-vm
> 专门负责:在 **Windows 10/11** 上,通过 **WSL2 + Ubuntu + QEMU/KVM + OneClick-macOS-Simple-KVM** 自动拉起一台 macOS 虚拟机Ventura 为默认),并处理常见网络 / WSL / KVM 问题。
## 触发场景
- 用户提到:**“龙虾”**、**“苹果系统虚拟机”**、**“Windows 上跑 macOS”**、**“一键安装 macOS 虚拟机”**
- 用户需要:在 Windows 上**演示 / 测试** macOS而不是 Docker 容器
- 用户遇到WSL 安装失败、`0x80072ee2` 网络错误、`kvm-ok``nested` 配置问题、`git clone` TLS 超时、OneClick 项目下载问题
## 核心能力
1. **环境检测与前置说明**
- 明确告诉用户:**macOS 不能在 Docker 里运行,必须用 WSL2 + 虚拟机**。
- 检查:
- `wsl -l -v` → 是否有 `Ubuntu-24.04`,是否为 Version 2
- `docker --version` 仅作背景信息,不作为必需条件
- 若缺失 WSL2
- 指导用户在**管理员 PowerShell**中执行:
- `wsl --install`
- 重启后执行 `wsl --install -d Ubuntu-24.04 --web-download`(必要时)
2. **固定部署目录约定**
- 所有与 macOS VM 相关的文件,统一放到:
- Windows 路径:`C:\Users\{USERNAME}\Mycontent\macos-vm`
- WSL 路径:`/mnt/c/Users/{USERNAME}/Mycontent/macos-vm`
- 该目录下结构:
- `OneClick-macOS-Simple-KVM/`(从 GitHub 下载的项目)
- `BaseSystem.dmg` / `BaseSystem.img`
- `macOS.qcow2`
- 可能还有 `OneClick.zip` 等临时文件
3. **获取 OneClick 源码(优先 zip退而求其次 git**
优先使用 **codeload.zip**,避免长时间 `git clone` TLS 超时:
-`macos-vm/` 目录内执行:
```bash
sudo apt-get update -qq
sudo apt-get install -y curl unzip
rm -rf OneClick-macOS-Simple-KVM OneClick.zip
curl -L --retry 8 --retry-delay 2 --connect-timeout 20 --max-time 600 \
-o OneClick.zip \
https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master
unzip -q OneClick.zip
mv OneClick-macOS-Simple-KVM-master OneClick-macOS-Simple-KVM
```
仅当用户网络环境允许且确有需要时,才尝试:
```bash
git clone --depth 1 https://github.com/notAperson535/OneClick-macOS-Simple-KVM.git
```
出现 `GnuTLS recv error (-110)` 或 TLS 断开时,**不要重复 git clone**,改走 zip 方案。
4. **依赖安装与 KVM 检查**
`Ubuntu-24.04` 内执行:
```bash
sudo apt-get update -qq
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker
kvm-ok
```
预期输出:
- `INFO: /dev/kvm exists`
- `KVM acceleration can be used`
`nested``N``kvm-ok` 正常:
- 提示用户在 `C:\Users\{USERNAME}\.wslconfig` 中写入:
```ini
[wsl2]
nestedVirtualization=true
kernel=C:\\Users\\{USERNAME}\\bzImage
debugConsole=true
pageReporting=true
kernelCommandLine=intel_iommu=on iommu=pt kvm.ignore_msrs=1 kvm-intel.nested=1 kvm-intel.ept=1 kvm-intel.emulate_invalid_guest_state=0 kvm-intel.enable_shadow_vmcs=1 kvm-intel.enable_apicv=1
```
并执行 `wsl --shutdown` 之后重试。
5. **下载 macOS Ventura 恢复镜像并生成 BaseSystem.img**
`OneClick-macOS-Simple-KVM` 目录内:
```bash
cd /mnt/c/Users/{USERNAME}/Mycontent/macos-vm/OneClick-macOS-Simple-KVM
chmod +x *.sh *.py
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
python3 fetch-macOS-v2.py -s ventura
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
ls -lah BaseSystem.* macOS.qcow2
```
成功标志:
- `BaseSystem.dmg` ≈ 678 MB
- `BaseSystem.img` ≈ 3.0 GB
- `macOS.qcow2` 已存在(几十 KB 起步)
6. **启动虚拟机headless + VNC: localhost:5900**
`OneClick-macOS-Simple-KVM` 目录内执行:
```bash
sudo HEADLESS=1 ./basic.sh
```
常见日志:
- ALSA / audio 报错(没有声卡驱动)→ **可以忽略**
- `BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00017"...` → 已经开始从虚拟硬盘启动
在 Windows 侧确认端口:
```powershell
Get-NetTCPConnection -LocalPort 5900 -State Listen
```
若监听正常,提示用户:
- 安装 VNC 客户端并连接 `localhost:5900`,进入 macOS 安装向导(磁盘工具抹盘 + 安装系统)。
- **常用 VNC 客户端**RealVNC Viewer、**TightVNC**用户环境已采用、TigerVNC 等均可,连接地址均为 `localhost:5900`
7. **WSL / 网络故障排查**
-`wsl` 进程过多、`wsl --shutdown` 卡死:
- 在 PowerShell 中执行:
```powershell
Get-Process -Name wsl -ErrorAction SilentlyContinue | Stop-Process -Force
wsl --shutdown
wsl -l -v
```
-`wsl --install``0x80072ee2` 或无法访问 `raw.githubusercontent.com`
- 提醒用户这是 **网络 / DNS 问题**,可尝试:
- 切换 DNS 到 `8.8.8.8`
- 使用合规代理 / VPN
- 使用 `--web-download` 方式安装发行版:
```powershell
wsl --install -d Ubuntu-24.04 --web-download
```
8. **Python 一键脚本lobster_macos_vm.py协同**
当仓库中存在 `开发文档/服务器管理/scripts/lobster_macos_vm.py` 时:
- 优先引导用户在 **PowerShell** 中执行:
```powershell
python C:\Users\{USERNAME}\Mycontent\macos-vm\lobster_macos_vm.py
```
- 该脚本应负责:
- 检查 / 安装 WSL2 + Ubuntu-24.04(必要时提示用户重启)
- 确保 `C:\Users\{USERNAME}\Mycontent\macos-vm` 目录存在
- 在 WSL 内下载 OneClick 源码 zip、解压到固定目录
- 安装 QEMU / Python 依赖并检查 `kvm-ok`
- 下载 Ventura 恢复镜像并生成 `BaseSystem.img`
- 启动 `sudo HEADLESS=1 ./basic.sh`
- 输出清晰的步骤说明(包括如何用 VNC 连接)
## 使用示例
- 用户说:「**龙虾,帮我在这台 Windows 上一键装一个 macOS 虚拟机,用来演示**」
- 按上述步骤依次执行:环境检测 → 创建 `macos-vm` 目录 → 下载 OneClick → 安装依赖 → 下载 Ventura → 生成 `BaseSystem.img` → 启动虚拟机,并提醒用户用 VNC 连 `localhost:5900` 完成图形安装。
- 用户说:「**龙虾,之前的 macOS 虚拟机挂了,重装一遍**」
- 复用相同目录和镜像文件,必要时重新下载 `BaseSystem.dmg`,再启动 `HEADLESS=1 ./basic.sh`

View File

@@ -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
},
// 加载 mpConfigappId、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用于支付加固错误处理避免审核报“登录报错”

View File

@@ -1,4 +1,7 @@
{
"usingComponents": {
"icon": "/components/icon/icon"
},
"pages": [
"pages/chapters/chapters",
"pages/index/index",

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

View File

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

View File

@@ -67,10 +67,7 @@ Component({
console.log('[TabBar] 开始加载功能配置...')
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config')
// app.request 的第一个参数是 url 字符串,第二个参数是 options 对象
const res = await app.request('/api/miniprogram/config', {
method: 'GET'
})
const res = await app.getConfig()
// 兼容两种返回格式

View File

@@ -38,7 +38,7 @@
<!-- 书籍信息 -->
<view class="book-info-card" wx:if="{{bookInfo && !authorLoading}}">
<text class="card-title">📚 {{bookInfo.title}}</text>
<view class="card-title"><icon name="book-open" size="36" color="#00CED1" customClass="card-title-icon"></icon><text>{{bookInfo.title}}</text></view>
<view class="book-stats">
<view class="book-stat">
<text class="book-stat-value">{{bookInfo.totalChapters}}</text>
@@ -65,7 +65,7 @@
<view class="contact-card" wx:if="{{!authorLoading}}">
<text class="card-title">联系作者</text>
<view class="contact-item">
<text class="contact-icon">🎉</text>
<icon name="sparkles" size="40" color="#00CED1" customClass="contact-icon"></icon>
<view class="contact-info">
<text class="contact-label">Soul派对房</text>
<text class="contact-value">每天早上6-9点开播</text>

View File

@@ -17,7 +17,8 @@
.stat-value { font-size: 36rpx; font-weight: 700; color: #00CED1; display: block; }
.stat-label { font-size: 22rpx; color: rgba(255,255,255,0.5); }
.contact-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; }
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: block; margin-bottom: 24rpx; }
.card-title { font-size: 28rpx; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 12rpx; margin-bottom: 24rpx; }
.card-title .card-title-icon { flex-shrink: 0; }
.contact-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 16rpx; }
.contact-item:last-child { margin-bottom: 0; }
.contact-icon { font-size: 40rpx; }

View File

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

View File

@@ -15,7 +15,7 @@
<!-- 收货人 -->
<view class="form-item">
<view class="form-label">
<text class="label-icon">👤</text>
<icon name="user" size="36" color="#8e8e93" customClass="label-icon"></icon>
<text class="label-text">收货人</text>
</view>
<input
@@ -30,7 +30,7 @@
<!-- 手机号 -->
<view class="form-item">
<view class="form-label">
<text class="label-icon">📱</text>
<icon name="smartphone" size="36" color="#8e8e93" customClass="label-icon"></icon>
<text class="label-text">手机号</text>
</view>
<input
@@ -47,7 +47,7 @@
<!-- 地区选择 -->
<view class="form-item">
<view class="form-label">
<text class="label-icon">📍</text>
<icon name="map-pin" size="36" color="#8e8e93" customClass="label-icon"></icon>
<text class="label-text">所在地区</text>
</view>
<picker
@@ -65,7 +65,7 @@
<!-- 详细地址 -->
<view class="form-item">
<view class="form-label">
<text class="label-icon">🏠</text>
<icon name="home" size="36" color="#8e8e93" customClass="label-icon"></icon>
<text class="label-text">详细地址</text>
</view>
<textarea
@@ -82,7 +82,7 @@
<!-- 设为默认 -->
<view class="form-item form-switch">
<view class="form-label">
<text class="label-icon"></text>
<icon name="star" size="36" color="#8e8e93" customClass="label-icon"></icon>
<text class="label-text">设为默认地址</text>
</view>
<switch

View File

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

View File

@@ -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/parts404 或失败时降级为 all-chapters 推导
// 懒加载:仅拉取篇章列表 + totalSections + fixedSectionsbook/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}` })

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '相似的创业方向' }
]
}
},

View File

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

View File

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

View File

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

View File

@@ -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="搜索导师、技能或行业..."

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,8 @@
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
.tag-region { background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
.tag-region { display: flex; align-items: center; gap: 8rpx; background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
.tag-region .tag-icon { flex-shrink: 0; }
/* 通用区块 */
.section {

View File

@@ -40,7 +40,7 @@ Page({
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
sectionMid: app.getSectionMid(id),
sectionMid: 0,
title: `章节 ${id}`,
amount: 1,
status: 'completed',
@@ -52,7 +52,7 @@ Page({
const purchasedSections = app.globalData.purchasedSections || []
this.setData({
orders: purchasedSections.map((id, i) => ({
id: `order_${i}`, sectionId: id, sectionMid: app.getSectionMid(id), title: `章节 ${id}`, amount: 1, status: 'completed',
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
}))
})

View File

@@ -28,7 +28,7 @@
</view>
<view class="empty" wx:else>
<text class="empty-icon">📦</text>
<icon name="package" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
<text class="empty-text">暂无订单</text>
</view>
</view>

View File

@@ -91,16 +91,21 @@ Page({
async onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
// 预加载 configlinkTags、auditMode 等(阅读页直接进入时需主动拉取最新审核状态
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
// 预加载core+auditModegetConfig+ 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 构造章节接口路径优先 midby-mid否则用 idby-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' })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,8 @@ class ChapterAccessManager {
*/
async fetchLatestConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/config', silent: true, timeout: 3000 })
if (res.success && res.prices) {
const res = await app.getConfig()
if (res && res.success && res.prices) {
return {
prices: res.prices || { section: 1, fullbook: 9.9 }
}

View File

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

View File

@@ -158,32 +158,21 @@ function buildTree(sections: SectionListItem[]): Part[] {
hotRank: s.hotRank ?? 0,
})
}
// 确保「2026每日派对干货」篇章存在不在第六篇编号体系内
const DAILY_PART_ID = 'part-2026-daily'
const DAILY_PART_TITLE = '2026每日派对干货'
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
if (!hasDailyPart) {
partMap.set(DAILY_PART_ID, {
id: DAILY_PART_ID,
title: DAILY_PART_TITLE,
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
})
}
const parts = Array.from(partMap.values()).map((p) => ({
...p,
chapters: Array.from(p.chapters.values()),
}))
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
const orderKey = (t: string) => {
if (t.includes('序言')) return 0
if (t.includes(DAILY_PART_TITLE)) return 1.5
if (t.includes('附录')) return 2
if (t.includes('尾声')) return 3
// 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB
const orderKey = (p: { id: string; title: string }) => {
if (p.title.includes('序言')) return 0
if (p.id === 'part-2026-daily') return 1.5
if (p.title.includes('附录')) return 2
if (p.title.includes('尾声')) return 3
return 1
}
return parts.sort((a, b) => {
const ka = orderKey(a.title)
const kb = orderKey(b.title)
const ka = orderKey(a)
const kb = orderKey(b)
if (ka !== kb) return ka - kb
return 0
})

View File

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

View File

@@ -223,6 +223,10 @@ func WarmBookPartsCache() {
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
//
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part + book/statsid↔mid 从各接口响应积累。
// 保留以兼容旧版/管理端,计划后续下线。
//
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
@@ -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 {

View File

@@ -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" 时仅返回 VIPhasFullBook
vipFilter := c.Query("vip") // "true" 时仅返回 VIPhasFullBook
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_biotags 存 ckb_tags
var body struct {
ID string `json:"id"`
Nickname *string `json:"nickname"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
ID string `json:"id"`
Nickname *string `json:"nickname"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
@@ -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
}

View File

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

View File

@@ -0,0 +1,5 @@
-- 修复篇章标题:将 slug 形式的 part_title 更新为展示标题数据来源DB
-- 执行node .cursor/scripts/db-exec/run.js -f soul-api/scripts/fix-part-titles.sql
-- part-2026-daily 的标题应为「2026每日派对干货」
UPDATE chapters SET part_title = '2026每日派对干货' WHERE part_id = 'part-2026-daily' AND (part_title = 'part-2026-daily' OR part_title = '' OR part_title IS NULL);

View File

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

View 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 | 首页/目录/我的/TabBarsearchEnabled、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
- 小程序从后台切回时需刷新 auditModeonShow 节流 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 确认非审核态
→ mpConfigappId、mchId 等)可从 core 或保留在 config 中,按需
```
---
## 四、实施步骤建议
### 阶段一:新增接口(不破坏现有)
1. **后端**:新增 `GetAuditMode``GetCoreConfig``GetReadExtras` 三个 handler
2. **缓存**audit-mode 单独 key、短 TTLcore、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 10minlink_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

View 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 | (备用) |
**PNGTab 激活态,可考虑改为 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`

View 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 均带 midgoToRead 用 dataset |
| **chapters** | book/parts + chapters-by-partsection 有 midfixedSections 有 mid |
| **search** | 搜索结果带 id+mid |
| **my** | recentChapters 来自 dashboard-stats后端返回 id+mid |
| **read** | _getChapterUrl 支持 by-mid/by-idprev/next 在详情内 |
| **gift-pay** | detail 含 productMid+productIdgoToRead 优先 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若后端未返回 middata-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 改为优先 midindex bookData 可清理。

View 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 同 URLGET 去重 |
| loadFeaturedFromServer | recommended + latest-chapters | ✓ | Promise.all |
| loadSuperMembers | vip/members → users | 串行 | 不足 4 人时补 users |
| loadLatestChapters | latest-chapters | ✓ | 与 loadFeatured 同 URL去重 |
**首屏请求汇总**(去重后):
- book/parts × 1
- config/core + audit-mode × 1app 已拉)
- 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.allread-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」即可带来明显收益。

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

View File

@@ -0,0 +1,124 @@
# Soul 创业派对 - 小程序与 API 性能优化综合分析
**分析时间**: 2026-03-18
**范围**: 小程序miniprogram+ 后端 APIsoul-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 |
| **启动预热** | WarmConfigCacheWarmAllChaptersCacheWarmBookPartsCacheWarmLatestChaptersCache | 首请求无冷启动 |
| **Gzip 压缩** | gin-contrib/gzip 中间件 | 响应体缩小 6080% |
| **内存兜底** | partsall-chapterschapters-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 |
**说明** 390430ms 主要为网络 RTT用户服务器Redis 命中时服务端处理通常 <20ms
---
## 二、待优化项(按优先级)
### 2.1 高优先级:减少重复请求
| 问题 | 现状 | 建议 |
|------|------|------|
| **config 多端重复** | app.loadMpConfigindex.loadFeatureConfigread TabBar 各自拉 config | 统一用 `app.globalData` 缓存首次拉取后 510min 内复用各页优先读缓存过期再拉 |
| **all-chapters 重复** | app.loadBookData + index.loadBookData 都调 all-chapters | index 优先用 `app.globalData.bookData`仅当为空或需刷新时再请求 |
| **loadFeaturedFromServer 串行** | recommended 失败再 hot latest-chapters | 可并行请求 recommended + latest-chapters失败再兜底 |
### 2.2 中优先级:请求策略
| 问题 | 现状 | 建议 |
|------|------|------|
| **首页请求过多** | initData 触发 loadBookDataloadFeaturedFromServerloadSuperMembersloadLatestChapters 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 12min缓存 |
| **CDN 静态资源** | uploads 图片走 CDN减轻 API 带宽压力 |
---
## 四、实施建议(分阶段)
### 阶段一快速见效12 天)
1. **config 统一缓存** app.js 维护 `configCache` + 过期时间各页优先读缓存
2. **index 复用 bookData**`app.globalData.bookData` 有值时index 不再单独请求 all-chapters
3. **loadFeaturedFromServer 并行**`Promise.all([recommended, latest-chapters])` 并行请求
### 阶段二结构优化35 天)
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` loadBookDataloadMpConfig |
| 首页数据 | 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骨架屏本地缓存节流等接口平均 390430ms主要受网络 RTT 影响
- **优先做**减少 configall-chapters 等重复请求统一缓存策略
- **中期**首页分阶段加载request 去重图片懒加载
- **长期**分包CDN性能监控
按上述阶段推进可在不大改架构的前提下明显减少请求数和首屏等待时间

View File

@@ -0,0 +1,220 @@
# Windows 上通过 WSL2 + QEMU/KVM 运行 macOS 虚拟机(安装方案)
> 目标:在 **Windows 10/11** 上,以 **WSL2Ubuntu+ 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` 发行版。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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