Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	.cursor/agent/软件测试/evolution/索引.md   resolved by yongxu-dev version
#	.cursor/skills/testing/SKILL.md   resolved by yongxu-dev version
#	.gitignore   resolved by yongxu-dev version
#	miniprogram/app.js   resolved by yongxu-dev version
#	miniprogram/app.json   resolved by yongxu-dev version
#	miniprogram/pages/chapters/chapters.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.js   resolved by yongxu-dev version
#	miniprogram/pages/index/index.wxml   resolved by yongxu-dev version
#	miniprogram/pages/match/match.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.js   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxml   resolved by yongxu-dev version
#	miniprogram/pages/my/my.wxss   resolved by yongxu-dev version
#	miniprogram/pages/read/read.js   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxml   resolved by yongxu-dev version
#	miniprogram/pages/read/read.wxss   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.js   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxml   resolved by yongxu-dev version
#	miniprogram/pages/wallet/wallet.wxss   resolved by yongxu-dev version
#	miniprogram/utils/ruleEngine.js   resolved by yongxu-dev version
#	miniprogram/utils/trackClick.js   resolved by yongxu-dev version
#	soul-admin/dist/index.html   resolved by yongxu-dev version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu-dev version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/api-docs/ApiDocsPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu-dev version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu-dev version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu-dev version
#	soul-api/.env.production   resolved by yongxu-dev version
#	soul-api/internal/database/database.go   resolved by yongxu-dev version
#	soul-api/internal/handler/balance.go   resolved by yongxu-dev version
#	soul-api/internal/handler/book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/ckb_open.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_book.go   resolved by yongxu-dev version
#	soul-api/internal/handler/db_person.go   resolved by yongxu-dev version
#	soul-api/internal/handler/search.go   resolved by yongxu-dev version
#	soul-api/internal/handler/upload.go   resolved by yongxu-dev version
#	soul-api/internal/router/router.go   resolved by yongxu-dev version
#	soul-api/wechat/info.log   resolved by yongxu-dev version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu-dev version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu-dev version
This commit is contained in:
Alex-larget
2026-03-17 14:23:26 +08:00
231 changed files with 14492 additions and 6576 deletions

View File

@@ -0,0 +1,7 @@
# 产品经理 经验记录 - 2026-03-16
## new-soul 派对AI 与 Mycontent 定位差异会议new-soul 新需求与当前项目差异分析)
- **new-soul 派对AI**:内容运营侧 AI 助手服务于《一场soul的创业实验》的派对→录屏→剪辑→成片→分发→文章→小程序全链路
- **当前 Mycontent**:产品侧,面向创业者的社区/工具型小程序,核心是内容→会员→导师变现、存客宝对接、分销等
- **结论**:两者是同一业务的不同层面(运营 vs 产品),互补非替代

View File

@@ -0,0 +1,13 @@
# 产品经理 经验记录 - 2026-03-17
## 稳定版源码质量优化会议2026-03-17
- **验收标准**:优化后现有功能行为不变,三端联调通过
- **优先级**:高优(安全)→ 中优(可维护)→ 低优(性能/结构)
- **原则**:源码质量优化按安全→可维护→性能分批,用户无感知
---
## 会议收尾2026-03-17
- 10 项优化全部完成;测试流程与报告模板已定稿;开发文档已同步

View File

@@ -0,0 +1,33 @@
# 2026-03-14 - 内容排名算法修正(排名分公式)
## 问题 / 场景
- 管理端「内容排行」与小程序「精选推荐」共用 `computeArticleRankingSections`,原算法错误:
- 使用「原始数值 × 权重」:`hot = readCnt×readWeight + payCnt×payWeight + recencyScore×recencyWeight`
- `recencyScore` 为 01 的天数衰减,非排名分
- 管理端修改权重后,列表不刷新(只调了 loadList未调 loadRanking
## 解决方案
### 算法修正db_book.go computeSectionsWithHotScore
- **公式**:热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分(三权重之和须为 1
- **排名分规则**
- 阅读量前 20 名:第 1 名=20 分 … 第 20 名=1 分,其余 0 分
- 最近更新前 30 篇:第 1 名=30 分 … 第 30 名=1 分,其余 0 分
- 付款数前 20 名:第 1 名=20 分 … 第 20 名=1 分,其余 0 分
- **权重配置**:从 `system_config.article_ranking_weights` 读取 readWeight、recencyWeight、payWeight
- **手动覆盖**:若 `chapters.hot_score > 0`,则优先使用该值
### 与前端约定
- 管理端保存权重后需同时调用 `loadList()``loadRanking()`,并关闭弹窗,列表才能立即刷新。
## 代码位置
- `soul-api/internal/handler/db_book.go``computeSectionsWithHotScore``computeArticleRankingSections`
- 管理端 `ContentPage.tsx``handleSaveRankingWeights` 中 loadRanking + setShowRankingAlgorithmModal(false)
## 影响
- 管理端内容排行榜、小程序精选推荐(`/api/miniprogram/book/recommended`)均复用该算法,修正后两端同步生效。

View File

@@ -0,0 +1,17 @@
# 后端工程师 经验记录 - 2026-03-16
## ParseAutoLinkContent 必须输出 data-label
- TipTap Mention 仅从 `data-label` 解析显示名,缺则回退显示 `data-id`token
- 新建 mention span`<span data-type="mention" data-id="TOKEN" data-label="名字">@名字</span>`
- 已损坏内容span 内为 token用 token 查 persons 取真实名字补回 data-label
## 存客宝创建计划参数
- planType=1、sceneId=9、scenario=9、status=1
- 管理端添加、文章 @ 自动创建两处均已同步
## new-soul 派对AI 与 content_upload.py会议new-soul 新需求与当前项目差异分析)
- content_upload.py 直连 DB 与 soul-api 并存,需核对 chapters 表结构与字段一致性
- 中长期可规划将文章上传迁移到 soul-api admin/db 接口,统一数据入口

View File

@@ -0,0 +1,73 @@
# 后端 - 2026-03-17
## 代付 PayNotify 权益归属修复
### 问题
代付支付回调中,`buyerUserID` 由 openID 解析得到,即**代付人**。权益激活全书、VIP、章节、余额充值和分佣均用 `buyerUserID`,导致权益错误给到代付人,而非发起人。
### 修复
引入 `beneficiaryUserID`(权益归属人):
- **代付订单**`beneficiaryUserID = order.UserID`(发起人)
- **普通订单**`beneficiaryUserID = buyerUserID`(付款人)
权益激活、分佣、取消未支付订单等逻辑统一改用 `beneficiaryUserID`
### 经验
- 代付场景:`order.user_id` = 发起人,`payer_user_id` = 代付人;权益与分佣必须按 `order.user_id` 处理
- PayNotify 中 openID 解析得到的是实际付款人,代付时需以 order 的 user_id 为权益归属
---
## gift-pay detail 返回 initiatorUserId
- 供小程序区分发起人/好友,展示不同 UI
- 字段:`initiatorUserId`(发起人 user_id
---
## 新版管理端迁移 - 后端任务会议2026-03-17
- **router 补齐**:迁移前注册 5 个路由:`db.GET("/users/rfm")``db.GET("/users/journey-stats")``admin.GET("/shensheshou/query")``admin.POST("/shensheshou/enrich")``admin.POST("/shensheshou/ingest")`
- **待确认**/api/admin/settings 是否已支持 ossConfig若不支持需补充
---
## 稳定版源码质量优化会议2026-03-17
- **敏感配置**生产环境MODE=release强制校验缺敏感 env 则 Fatal
- **user/track 鉴权**:新增 GET /api/admin/user/track + AdminAuth原 /api/user/track 保留给小程序 POST 埋点
- **AdminWithdrawTest**:非 develop 环境返回 404 或拒绝
---
## 会议收尾2026-03-17
- 源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过
---
## 性能优化与 Redis 缓存方案落地2026-03-17
### Redis 缓存
- **internal/cache**Get/Set/Del、GetString/SetStringRedis 不可用时回退 DB
- **已缓存**book/parts、hot、recommended、stats、config、章节 content
- **失效**InvalidateBookParts、InvalidateBookCache、InvalidateConfig、InvalidateChapterContent
### OSS 上传
- **internal/oss**LoadConfig、Upload、Delete失败回退本地
- 配置从 system_config.oss_config 读取
### /health
- 返回 database、redis 连接状态ok/disconnected/disabled
### 经验
- Redis 容灾:未配置或失败时回退 DB不阻塞业务
- 缓存 keysoul:{业务}:{标识}

View File

@@ -6,3 +6,6 @@
| 2026-03-05 | 文章详情@某人content 内嵌 @ 标记、miniprogram 添加好友接口 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp接口边界不变overview 聚合接口可选但需降级 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-12 | persons token 字段与 DB 迁移CKBLead 用 token 兑换 ckb_api_key | [2026-03-12.md](./2026-03-12.md) |
| 2026-03-14 | 内容排名算法修正:排名分公式(阅读/新度/付款前 N 名),支持 hot_score 手动覆盖 | [2026-03-14.md](./2026-03-14.md) |
| 2026-03-16 | ParseAutoLinkContent data-label存客宝 create planType/sceneId/status | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付 PayNotify beneficiaryUserID 权益归发起人gift-pay detail 返回 initiatorUserId | [2026-03-17.md](./2026-03-17.md) |

View File

@@ -0,0 +1,17 @@
# 2026-03-14 - 内容排名算法跨端复用约定
## 业务规则
- **热度积分公式**:阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分(三权重之和须为 1
- **排名分规则**:阅读量前 20 名20~1 分)、最近更新前 30 篇30~1 分)、付款数前 20 名20~1 分)
## 跨端复用
- **管理端**`/api/db/book?action=ranking``computeArticleRankingSections`
- **小程序**`/api/miniprogram/book/recommended``computeArticleRankingSections`(取前 3 条)
- 两者共用同一套算法、权重配置(`article_ranking_weights`)、置顶配置(`pinned_section_ids`
## 约定
- 排名算法修改只需改 soul-api 一处,管理端与小程序自动同步。
- 管理端保存权重后必须调用 `loadRanking()` 刷新列表,否则用户看不到变化。

View File

@@ -0,0 +1,12 @@
# 团队 经验记录 - 2026-03-16
## TipTap Mention 显示规则
- **data-label 必填**TipTap Mention 的 label 仅从 `data-label` 解析,不解析 span 内文本
- 后端 ParseAutoLinkContent 输出 mention 时必须含 data-label否则管理端重开后显示 token 而非名字
## new-soul 派对AI 与 Mycontent 关系会议new-soul 新需求与当前项目差异分析)
- new-soul 派对AI魂AI9 技能 5 组:魂资/魂流/魂产/魂码/魂质
- 与 Mycontent 三端soul-api、soul-admin、miniprogram为同一业务不同层面运营侧 vs 产品侧
- 路径差异Mac vs Windows需在文档中说明

View File

@@ -0,0 +1,66 @@
# 团队 - 2026-03-17
## 代付美团式流程与权益归属约定
### 流程约定
1. **入口**:读页「找好友代付」→ 创建请求 → **跳转代付详情页**(不再弹窗)
2. **代付页**:发起人看到「分享给好友」,好友看到「帮他付款」
3. **后端**detail 返回 `initiatorUserId`,前端据此区分
### 权益与分佣约定
- 代付订单:`order.user_id` = 发起人,`payer_user_id` = 代付人
- 权益全书、VIP、章节、余额充值归属发起人
- 分佣按发起人的推荐关系计算
- PayNotify 中 openID 解析得到的是代付人,权益与分佣必须用 `order.user_id`
### 同时影响
- 小程序:代付详情页双态 UI、读页跳转
- 后端PayNotify beneficiaryUserID、detail initiatorUserId
---
## 新版管理端迁移到稳定版会议2026-03-17
### 决议
- **内容管理**:以稳定版为主,不采纳新版
- **新版独有**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑 → **全部吸纳**
- **后端**:迁移前补 routerusers/rfm、journey-stats、shensheshou 共 5 个)
### 影响角色
- 管理端开发工程师:主导迁移
- 后端开发router 补齐、ossConfig 确认
- 测试人员:迁移后验收
---
## 稳定版源码质量优化会议2026-03-17
- **原则**:增量修复、不改功能逻辑;高优安全项优先,中优可维护项次之,低优可后续迭代
- **影响角色**:后端、管理端、小程序、测试
---
## 会议收尾2026-03-17
- 源码优化 10 项全部完成;功能测试流程定稿;开发环境测试 10 通过 2 跳过
---
## 性能优化与 Redis 缓存方案落地2026-03-17
### 架构约定
- **Redis 容灾**:不可用时回退 DB不阻塞业务
- **缓存 key**soul:{业务}:{标识}
- **OSS 上传**:优先 OSS失败回退本地
### 影响角色
- 后端开发cache、oss 包;/health 增强
- 管理端开发工程师OSS 配置后上传自动优先 OSS
- 测试人员test_upload.py、/health 验证、部署后回归缓存接口

View File

@@ -7,3 +7,6 @@
| 2026-03-05 | 分支冲突后各端完整性自查流程 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp菜单/布局新规范基线与入口收敛规则 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token不暴露真实密钥、服务端兑换 | [2026-03-12.md](./2026-03-12.md) |
| 2026-03-14 | 内容排名算法跨端复用:管理端内容排行与小程序精选推荐共用 computeArticleRankingSections | [2026-03-14.md](./2026-03-14.md) |
| 2026-03-16 | TipTap Mention 需 data-label否则显示 token | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人 | [2026-03-17.md](./2026-03-17.md) |

View File

@@ -0,0 +1,40 @@
# 阅读页文本长按选中复制
> 问题→解决闭环,已升级 miniprogram-dev SKILL
---
## 问题
小程序阅读页正文长按时无法选中、复制文本。
---
## 解决方案
使用 `text` 组件的 **`user-select`** 属性(基础库 2.12.1+,官方推荐;`selectable` 已废弃):
```html
<text user-select>{{seg.text}}</text>
```
---
## 实施范围read.wxml
- 章节标题:`<text class="chapter-title" user-select>`
- 正文片段text / mention / linkTag 均加 `user-select`
- 预览段落:`<text user-select>{{item}}</text>`(原为 view 内直接 `{{item}}`,需用 text 包裹)
---
## 备选方案
- **selectable**:已废弃但多数环境仍可用,若 `user-select` 导致布局异常inline-block 换行)可回退
- **wx.setClipboardData + bindlongpress**iOS 原生选中失效时,可做长按整段复制兜底
---
## Skill 升级
已写入 miniprogram-dev SKILL §10 文本可选与复制。

View File

@@ -0,0 +1,90 @@
# 联网吸收:微信小程序最新开发规则与 API2025-03
> 来源微信开放文档、基础库更新日志、Skyline 文档、隐私合规指南等
---
## 一、基础库版本与更新节奏
- **当前最新**v3.14.22026-01-22v3.14.3 灰度中
- **建议**:在 `app.json` 中设置 `"useExtendedLib": { "weui": true }` 或指定 `libVersion`,关注灰度版本说明
- **兼容**:使用 `wx.canIUse('api.xxx')` 做能力检测,避免在低版本报错
---
## 二、新增 / 重要 API基础库 3.14 系列)
| API | 用途 | 基础库 |
|-----|------|--------|
| `wx.rewriteRoute` | 路由重写 | 3.14+ |
| `wx.openOfficialAccountProfile` | 打开公众号 | 3.14+ |
| `wx.openOfficialAccountChat` | 跳转公众号会话 | 3.14+ |
| `wx.openInquiriesTopic` | 跳转问一问话题 | 3.14.0 |
| `wx.loadBuiltInFontFace` | 加载微信内置字体 | 3.14+ |
| `wx.onUserOffTranslation` | 监听用户关闭翻译 | 3.14.3 灰度 |
**其他能力**
- 鼠标右键点击事件支持PC 端)
- 图片分享朋友圈
- 半屏小程序 `openEmbeddedMiniProgram` 上限提升到 100
- TCPSocket 支持 `TCP_NODELAY`
---
## 三、Skyline 渲染引擎(可选升级)
- **定位**:新一代渲染引擎,以性能为首要目标,仍用 WXML/WXSS
- **配置**:页面级 `page.json``"renderer": "skyline"`
- **性能**:启动耗时降约 20%,跳页耗时降约 50%;长列表 `scroll-view` 仅渲染屏内节点
- **注意**CSS 特性精简,只保留更现代的集合;鸿蒙 OS 已灰度支持
- **Soul 项目**:当前为 WebView 渲染,若需性能优化可逐步按页面接入 Skyline
---
## 四、隐私合规2025 重要变更)
### 4.1 核心变化:从集中授权改为按需授权
- **旧**:首次启动一次性请求所有权限
- **新**:必须在用户**实际触发相关功能时**才发起对应授权请求
- **影响**:需拆分授权逻辑到具体业务场景,不能集中在 `app.onLaunch`
### 4.2 必须完成的步骤
1. **后台配置**:在小程序管理后台填写《小程序用户隐私保护指引》,声明处理的用户信息类型及用途
2. **查询与展示**`wx.getPrivacySetting` 查询授权状态,`wx.openPrivacyContract` 打开隐私协议
3. **获取同意**:使用 `<button open-type="agreePrivacyAuthorization">` 获取用户明示同意,用户点击后微信同步状态,开发者才可调用已声明的隐私接口
### 4.3 敏感权限
- 通讯录、位置、摄像头、麦克风、相册等**不会默认开启**
- 需用户明确同意后才可调用
- 需为「用户拒绝」设计降级方案
---
## 五、网络请求wx.request
- **官方**`wx.request` 仍为主流,仅支持回调,不支持原生 Promise
- **Soul 项目**:已用 `app.request` 封装,支持 Promise、统一 baseUrl、鉴权、错误处理符合规范
- **第三方**:若需更丰富能力(拦截器、重试等),可考虑 wechat-http、mini-quest 等
---
## 六、与 Soul 项目 SKILL 的衔接
| 联网吸收内容 | Soul miniprogram-dev SKILL 对应 |
|-------------|--------------------------------|
| 隐私按需授权 | 登录、手机号、推荐码等涉及隐私的接口,应在用户触发时再请求,避免启动时集中授权 |
| Skyline 可选 | 当前 SKILL 未强制 Skyline性能敏感页面可单独配置 `renderer: skyline` |
| 新 API | 路由重写、公众号跳转等按需使用,调用前用 `wx.canIUse` 检测 |
| 基础库版本 | 建议在项目文档中注明最低支持版本,便于兼容性排查 |
---
## 七、建议动作
1. **隐私合规**:检查 `app.js` 及登录/手机号/推荐码流程,确保按需授权,不在启动时集中请求
2. **文档**:在 README 或开发文档中注明基础库最低版本(如 2.19.0 或 3.0.0
3. **能力检测**:新增依赖新 API 的功能时,使用 `wx.canIUse` 做降级
4. **Skyline**:阅读页、章节列表等长列表页面可评估 Skyline 接入收益

View File

@@ -0,0 +1,18 @@
# 2026-03-14 - 我的页设置隐藏与资料引导场景梳理
## 1. 设置入口隐藏
- **需求**:我的页「设置」菜单项隐藏
- **实现**`my.wxml` 中设置菜单项加 `wx:if="{{false}}"`
- **说明**:设置页仍存在,用户可通过推广页「去设置」等入口进入(如绑定微信号用于提现)
## 2. 资料修改引导场景(何时引导用户修改资料)
| 场景 | 位置 | 触发条件 | 行为 |
|------|------|----------|------|
| 登录后 | app.js `_ensureProfileCompletedAfterLogin` | 昵称空/默认/头像空 | Toast「请先完善头像和昵称」→ 跳转 profile-edit |
| 阅读页 @某人 | read.js `_doMentionAddFriend` | 已登录但无手机/微信号 | 弹窗「完善资料」→ 确认跳转 profile-edit |
| 找伙伴页 | match.js `ensureContactInfo` | 已登录但无手机/微信号 | 页面内弹窗填写联系方式(不跳转) |
| 首页链接卡若 | index.js | 无手机/微信号 | 弹窗输入手机号(不跳转 profile-edit |
- **判断依据**`_isProfileIncomplete(user)` 检查 nickname、avatar手机/微信号需单独拉 `user/profile` 接口判断。

View File

@@ -0,0 +1,34 @@
# 2026-03-16 编辑资料页分享名片
## 问题/场景
编辑资料页转发给朋友、分享到朋友圈时,需做特殊处理:直接变成「分享名片」,而非普通页面分享。分享标题、封面需体现用户身份与名片信息。
## 解决方案
1. **分享标题**`昵称+为您分享名片`(如「少年梦想家为您分享名片」)
2. **分享封面**Canvas 绘制名片图5:4 比例,符合微信分享图规范)
- 布局:左头像(圆形)+ 右昵称 +「个人名片」副标题
- 分隔线
- 四栏信息:地区 | MBTI、行业 | 职位(标签灰、数值白,统一色值)
3. **分享路径**:转发/朋友圈均指向 `member-detail?id=userId`,好友打开即见名片详情
4. **朋友圈重定向**:分享带 `id` 时,`profile-edit``onLoad` 检测到 `options.id``redirectTo``member-detail`
## 技术要点
- **预生成**:资料加载完成后、头像更新后调用 `generateShareCard()`,将 `shareCardPath` 存入 data
- **Canvas**:隐藏 canvas 500×400`wx.createCanvasContext` + `ctx.draw(true)` + `wx.canvasToTempFilePath`
- **头像下载**:网络头像需 `wx.downloadFile`,域名须配置 downloadFile 合法域名;失败时用昵称首字母占位
- **文本截断**`ctx.measureText` 超宽时截断加「…」
## 适用角色
小程序开发工程师
## 升级 Skill
miniprogram-dev SKILL 新增 §10 分享名片
## new-soul 派对AI 与小程序会议new-soul 新需求与当前项目差异分析)
- 派对AI 的小程序站管理与当前 miniprogram 一致,仅涉及发布流程,不改变小程序功能

View File

@@ -0,0 +1,15 @@
# 小程序开发工程师 经验记录 - 2026-03-17
## 稳定版源码质量优化会议2026-03-17
- **payment.js**确认无引用可删除read.js 直接调 /api/miniprogram/pay
- **goToMatch**my.js 重复定义,删除一个
- **备份文件**:删除 read.js.backup、referral.wxss.backup
- **appId 等**:优先从 config 的 mpConfig 读取,保留兜底
- **totalSections**:从 book/stats 或 all-chapters 动态获取,保留 62 兜底
---
## 会议收尾2026-03-17
- 源码优化 5 项全部完成;开发环境测试通过

View File

@@ -2,9 +2,14 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2025-03-14 | 联网吸收:基础库 3.14、Skyline、隐私按需授权、新 API | [2025-03-14-联网吸收小程序最新开发规则与API.md](./2025-03-14-联网吸收小程序最新开发规则与API.md) |
| 2025-03-14 | 阅读页文本长按选中复制text 组件 user-select | [2025-03-14-文本长按复制.md](./2025-03-14-文本长按复制.md) |
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
| 2026-03-03 | 我的页面卡片区边距优化16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
| 2026-03-05 | 分支合并后核心流程自测app.json 拆行orders 接口确认 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人高亮与一键加好友(解析@、调添加好友接口) | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp关注内容产物格式与阅读页解析兼容回归 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-12 | 链接标签 mpKey、@ 人物 token 兑换contentParser、onLinkTagTap、onMentionTap | [2026-03-12.md](./2026-03-12.md) |
| 2026-03-14 | 我的页设置入口隐藏;资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | [2026-03-14.md](./2026-03-14.md) |
| 2026-03-16 | 编辑资料页分享名片:转发/朋友圈特殊处理Canvas 绘制封面,标题「昵称+为您分享名片」 | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) |

View File

@@ -0,0 +1,91 @@
# 用户交互习惯分析(基于 agent-transcripts 抽样)
> 乘风发起,读取 97 个 agent 会话记录,抽样分析后总结。用于优化 agent 响应策略与 Skill 设计。
---
## 一、角色与触发词使用习惯
| 触发词/角色 | 使用场景 | 期望动作 |
|-------------|----------|----------|
| **乘风** | 开会、同步进度、协调开发、总结经验 | 老板分身主持,协调各角色 |
| **开会** | 需求评审、方案讨论、进度同步 | 按 team-meeting SKILL 主持多角色会议 |
| **吸收经验** | 功能完成、讨论完毕 | 经验入库 + Skill 升级 + 同步需求文档 |
| **橙子 / 小橙** | 记录、同步文档、会议收尾 | 文档同步、纪要生成、索引更新 |
| **加个需求xxx** | 新增功能 | 产品经理三端分析 → 功能规划 → 指派 |
| **变更完成 / 检查一下** | 代码改完 | 过 change-checklist 三端关联检查 |
---
## 二、表达方式偏好
### 1. 直接点名角色
- 如:「小程序工程师」「后端工程师」「管理端工程师」「测试工程师」
- 期望agent 按该角色 Skill 执行,不混用其他端逻辑
### 2. @ 文件引用
- 如:@new-soul、@scripts@soul-admin、@开发文档
- 期望agent 读取并理解引用内容,作为上下文执行任务
### 3. 任务导向、先分析再实现
- 典型句式:「帮我对比」「整理出迁移清单」「分析有没有逻辑盲点」「从经营角度看看」
- 期望:先出方案/文档,再写代码;不直接动手改
### 4. 图片辅助反馈
- 经常截图 + 文字描述问题(如「@匹配有问题」「弹窗太大」「封面图要长这样」)
- 期望agent 结合截图理解 UI/交互问题,给出针对性修改
---
## 三、工作流程偏好
| 阶段 | 习惯 | 说明 |
|------|------|------|
| **需求** | 以需求驱动、以界面定需求 | 不凭空加功能,需求与实现可追溯 |
| **分析** | 先对比、出方案、写文档 | 迁移清单、逻辑盲点、技术协调、经营建议 |
| **实现** | 小步迭代、可读性优先 | 函数单一职责,避免深层嵌套 |
| **验收** | 测试 → 吸收经验 → 同步文档 | 闭环经验入库、Skill 升级、需求汇总更新 |
| **会议** | 开会 → 各角色发言 → 决议 → 橙子总结 | 会议结束触发收尾流程 |
---
## 四、沟通风格
- **简洁**:如「帮我处理」「帮我测试一下」「修复」
- **追问细节**:如「界面有什么变化」「有逻辑盲点吗」「从经营角度看看」
- **强调合理性**:如「产品经理要根据实际情况判断,不能随意增加管理列表」
- **确认后执行**:如「先帮我对比」「整理出迁移清单」——先出结果再决定是否实现
---
## 五、技术偏好Soul 项目)
- **三端隔离**:小程序只调 `/api/miniprogram/*`,管理端只调 `/api/admin/*``/api/db/*`
- **存客宝对接**Person、LinkTag、ckb_api_key、planType、sceneId、status 等参数约定
- **测试规范**scripts/test 目录miniapp / web / process 分类pytest + requests运行前显式提示测试环境
---
## 六、反馈与确认习惯
| 用户表达 | 触发动作 |
|----------|----------|
| 「搞定了」「可以了」「解决了」 | 经验自动收集(老板分身-索引.mdc |
| 「会议结束」「散会」 | 助理橙子会议收尾 |
| 「吸收经验」「同步到需求文档」 | 经验入库 + 需求汇总更新 |
| 「不要记录」「不用沉淀」 | 不触发经验入库 |
---
## 七、Agent 响应建议
1. **先理解再动手**用户说「帮我xxx」时先确认范围、出方案再写代码
2. **按角色执行**:用户点名角色时,必须 Read 对应 Skill按规范执行
3. **文档先行**:迁移、重构类任务,先出清单/分析文档,再实现
4. **闭环收尾**:功能完成时主动提示「可以说吸收经验同步到文档」
5. **图片理解**:用户发截图时,结合截图分析问题,不只看文字
---
**来源**agent-transcripts 抽样(约 15 个会话),结合经验清单与项目索引交叉验证。
**适用**:开发助理、老板分身、各角色 agent 的响应策略优化。

View File

@@ -0,0 +1,9 @@
# 开发助理 经验记录 - 2026-03-17
## 会议收尾2026-03-17
- **纪要**2026-03-17_会议收尾-源码优化完成与测试流程定稿.md
- **经验入库**:各角色 evolution 已追加收尾经验
- **项目索引**:后端、管理端、小程序、测试、助理橙子已更新
- **会议索引**README.md 已追加
- **开发文档**:运营与变更第十七部分已追加

View File

@@ -4,3 +4,4 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts | 2026-03-16-交互习惯分析.md |

View File

@@ -40,6 +40,18 @@
| 2026-03-12 | 后端、团队 | 业务规则 | api-dev SKILL、team three-tier-arch | 9.9 买断统一由后端折叠为 hasFullBook/has_full_book小程序和管理端只认该信号可通过用户资料开关 OR 订单实现赠送全书 |
| 2026-03-13 | 小程序、后端、团队 | 业务规则 | api-dev SKILL、miniprogram-dev SKILL、three-tier-arch SKILL | 文章详情预览统一由后端按 50% 截取,小程序按 accessState 使用预览/全文,外层 content 与 data.content 始终一致以避免泄露全文 |
| 2025-03-14 | 小程序 | 最佳实践 | miniprogram-dev SKILL §10 | 阅读页文本长按选中复制text 组件 user-selectselectable 已废弃),正文/标题/预览均加 user-select |
| 2026-03-14 | 后端、管理端、小程序、团队 | 业务规则/bug 修复 | - | 内容排名算法修正(排名分公式);保存权重后 loadRanking 刷新;我的页设置隐藏;资料引导场景梳理 |
| 2026-03-16 | 软件测试 | 知识沉淀 | testing SKILL | scripts 目录与测试关联:本地启动.sh 联调必备、飞书脚本非回归范围、soul-api/scripts 与根 scripts 区分 |
| 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/testminiapp 小程序接口测试、web 管理端测试;测试工程师在此编写用例 |
| 2026-03-16 | 软件测试 | 目录约定 | testing SKILL §5 | scripts/test/process流程测试跨端多接口串联下单→支付→分润等 |
| 2026-03-16 | 软件测试 | 配置约定 | testing SKILL | pytest 架构、配置从 soul-api/.env* 读取、SOUL_TEST_ENV 必显;运行前报告头部显示测试环境,避免误测正式库 |
| 2026-03-16 | 小程序 | 最佳实践 | miniprogram-dev SKILL §10 | 编辑资料页分享名片:转发/朋友圈特殊处理Canvas 绘制 5:4 封面,标题「昵称+为您分享名片」,路径 member-detail |
| 2026-03-16 | 开发助理 | 交互习惯分析 | - | 乘风读取 agent-transcripts 抽样分析角色触发词、表达方式、工作流程、沟通风格、技术偏好、Agent 响应建议 |
| 2026-03-17 | 小程序、后端、团队 | 业务规则/bug 修复 | - | 代付美团式读页→代付页→分享PayNotify beneficiaryUserID 权益归发起人detail 返回 initiatorUserId目录 loading、最新新增 5 条折叠 |
| 2026-03-17 | 小程序 | 业务规则 | - | 代付统一到代付页gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 |
| 2026-03-17 | 软件测试 | 流程定稿 | testing SKILL | 功能测试流程:成功 ☑、失败列问题、最终报告scripts/test/功能测试流程.md、测试报告-环境与用例清单.md |
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存parts/hot/recommended/stats/config/章节 content容灾回退 DBOSS 上传;/health 返回 database/redis 状态 |
---
@@ -50,4 +62,4 @@
---
**最后更新**2026-03-13文章预览规则统一、小程序与后端对齐
**最后更新**2026-03-17会议收尾源码优化完成与测试流程定稿

View File

@@ -21,9 +21,12 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-05 | 文章详情@某人加好友方案讨论:验收标准、添加好友接口 path 待确认 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp新菜单/布局与入口收敛验收口径确定 | 待续 |
| 2026-03-11 | 以界面定需求文档建立;需求基准以《以界面定需求》为准 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;验收标准功能不变、三端联调通过 | 待续 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-11
**最后更新**2026-03-17

View File

@@ -17,9 +17,16 @@
| 2026-02-26 | 项目索引初始化经验库五角色目录结构搭建SKILL 补充角色映射表与跨端写入规则 | 已完成 |
| 2026-02-28 | .cursor 按 cursor标准模板 重构agent 目录、config、evolution.py、meeting | 已完成 |
| 2026-03-11 | 会议收尾:开发团队对齐业务逻辑与以界面定需求;纪要生成、各角色经验入库、项目索引与会议索引更新 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 乘风读取 agent-transcripts 分析交互习惯,总结经验并吸收 | 已完成 |
| 2026-03-17 | 吸收需求代付美团式流程、PayNotify 权益归属、目录 loading、最新新增 5 条折叠 → 开发文档与 agent | 已完成 |
| 2026-03-17 | 乘风吸收经验与交互:迁移完成度与待办清单、运营与变更第十二部分 | 已完成 |
| 2026-03-17 | 吸收新需求代付统一到代付页gift=1&ref redirectTo→ 需求汇总、找朋友代付流程、运营与变更 | 已完成 |
| 2026-03-17 | 会议收尾:新版管理端迁移到稳定版实施方案确认;纪要、各角色经验入库、项目索引更新 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿;纪要、经验入库、开发文档同步 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-11
**最后更新**2026-03-17会议收尾源码优化完成与测试流程定稿

View File

@@ -27,9 +27,18 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-12 | persons 表新增 token 字段add-persons-token.sqlCKBLead 用 token 兑换 ckb_api_key | 已完成 |
| 2026-03-12 | 9.9 买断后端开关方案users 增手动 fullbook 开关purchase-status/check-purchased 折叠为统一 hasFullBook/has_full_book小程序免改即支持赠送全书 | 已完成 |
| 2026-03-13 | 文章详情预览统一与安全previewContent 改为截取正文前 50%findChapterAndRespond 保证外层 content 与 data.content 一致,未授权只返回预览 | 已完成 |
| 2026-03-14 | 内容排名算法修正computeSectionsWithHotScore 改为排名分公式(阅读/新度/付款前 N 名得分),支持手动 hot_score 覆盖 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | ParseAutoLinkContent 添加 data-label存客宝 create planType=1 sceneId=9 status=1 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析content_upload.py 与 chapters 一致性待核对 | 待续 |
| 2026-03-17 | 代付 PayNotify 权益归属修复beneficiaryUserID代付=发起人gift-pay detail 返回 initiatorUserId | 已完成 |
| 2026-03-17 | 会议新版管理端迁移router 补齐 users/rfm、journey-stats、shensheshou 共 5 个;确认 ossConfig | 进行中 |
| 2026-03-17 | 会议:稳定版源码质量优化;敏感配置生产强制校验、新增 /api/admin/user/track、AdminWithdrawTest 环境限制 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过 | 已完成 |
| 2026-03-17 | 性能优化会议Redis 缓存接入parts/hot/recommended/stats/config/章节 content、容灾回退 DBOSS 上传接入;/health 返回 database/redis 状态 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-13
**最后更新**2026-03-17

View File

@@ -24,9 +24,14 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token不暴露真实密钥、服务端兑换 | 已完成 |
| 2026-03-12 | 9.9 买断团队约定:后端统一输出 hasFullBook/has_full_book小程序和管理端只认该信号支持通过用户资料开关 OR 订单赠送全书且不影响 VIP 逻辑 | 已完成 |
| 2026-03-13 | 文章详情预览规则统一:预览长度由后端统一按 50% 计算,小程序按 accessState 切换预览/全文,接口约定 content 与 data.content 始终一致 | 已完成 |
| 2026-03-14 | 内容排名算法跨端复用:管理端内容排行与小程序精选推荐共用 computeArticleRankingSections排名分公式、权重配置统一 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | TipTap Mention 需 data-label 规则;链接人与事与存客宝对接优化会议收尾 | 已完成 |
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人PayNotify beneficiaryUserID | 已完成 |
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-13
**最后更新**2026-03-17

View File

@@ -29,9 +29,19 @@
| 2026-03-11 | 以界面定需求:小程序界面清单纳入《以界面定需求》;展示以用户资料为准,与现有实现一致 | 已完成 |
| 2026-03-12 | 链接标签 mpKey 兑换 appId@ 人物 token 兑换 ckb_api_keycontentParser、onLinkTagTap、onMentionTap | 已完成 |
| 2026-03-13 | 阅读页文章预览与付费解锁对齐:预览长度改由后端统一计算,前端按 accessState 显示预览/全文,避免 data.content 泄露全文 | 已完成 |
| 2025-03-14 | 阅读页文本长按选中复制text 组件 user-select已升级 SKILL §10 | 已完成 |
| 2026-03-14 | 我的页设置入口隐藏wx:if资料修改引导场景梳理登录后、@某人、找伙伴、链接卡若) | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 编辑资料页分享名片:转发/朋友圈改为分享名片Canvas 封面(头像+昵称+四栏信息),路径 member-detail | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析派对AI 小程序站管理与当前一致 | 已完成 |
| 2026-03-17 | 代付美团式:读页→创建请求→跳转代付详情页;详情页双态(发起人分享/好友帮他付款);目录 loading、首页最新新增 5 条折叠 | 已完成 |
| 2026-03-17 | 代付统一到代付页gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 | 已完成 |
| 2026-03-17 | 代付页营销:章节标题+20%内容预览;我的代付列表点击进详情;页面协调 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;删除 payment.js、goToMatch 重复、备份文件config 读取、totalSections 动态化 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-13
**最后更新**2026-03-17

View File

@@ -20,7 +20,17 @@
| 2026-03-05 | 分支冲突后功能完整性分析会议:制定「分支合并后回归清单」 | 待续 |
| 2026-03-05 | 文章详情@某人加好友方案讨论@ 展示与添加好友用例、联调与回归 | 待续 |
| 2026-03-10 | 会议:管理端迁移 Mycontent-temp回归重点为菜单一致性、隐藏路由可达性、鉴权跳转 | 待续 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | scripts 目录知识吸收:本地启动、飞书脚本、联调环境准备 | 已完成 |
| 2026-03-16 | scripts/test 测试用例目录约定miniapp 小程序接口、web 管理端 | 已完成 |
| 2026-03-16 | scripts/test/process 流程测试目录:跨端业务流程 | 已完成 |
| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | 已完成 |
| 2026-03-16 | 文章 @某人 自动创建存客宝:用例编写、执行、报告;归档规则 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析引入派对AI 时回归文章上传/飞书推送/小程序展示 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;每项小回归、全部完成后完整三端联调 | 待续 |
| 2026-03-17 | 会议收尾:功能测试流程定稿、测试报告模板、开发环境 10 通过 2 跳过 | 已完成 |
| 2026-03-17 | 性能优化会议test_upload.py 6 用例;/health 可验证 database/redis部署后回归缓存接口 | 已完成 |
---
**最后更新**2026-03-10
**最后更新**2026-03-17

View File

@@ -8,6 +8,13 @@
管理端React + Vite + Tailwind主要功能用户管理、订单管理、提现审核、VIP 管理、内容/章节管理、配置项管理、数据统计。调用 `/api/admin/*``/api/db/*` 接口JWT Bearer 鉴权。
### 项目路径定义
| 版本 | 路径 | 说明 |
|------|------|------|
| **稳定版(主用)** | `soul-admin/` | 根目录,线上部署 |
| **新版(参考)** | `new-soul/soul-admin/` | 迁移时对照,含 ApiDocsPage 完整版、OSS region 等 |
---
## 开发进度
@@ -24,9 +31,21 @@
| 2026-03-10 | Toast 通知系统落地:创建 utils/toast.ts纯原生全系统 18 文件约 90 处 alert 全部替换为 toast.success/error/info | 已完成 |
| 2026-03-11 | 以界面定需求:管理端界面清单纳入《以界面定需求》,作为验收基准 | 已完成 |
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计(链接标签存 key、PersonItem.id=token | 已完成 |
| 2026-03-14 | 排名算法权重保存后刷新handleSaveRankingWeights 成功后 loadRanking + 关闭弹窗,列表立即更新 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 链接人与事table 布局、planId/apiKey 列、复制图标、删除 Dialog 弹窗 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析派对AI 不新增管理端需求 | 已完成 |
| 2026-03-17 | 会议:新版管理端迁移到稳定版实施方案确认;新版独有全部吸纳,内容管理以稳定版为主 | 已完成 |
| 2026-03-17 | 吸收新版管理端定义new-soul/soul-admin迁移 ApiDocsPage 完整版、OSS region、鉴权失败 clearAdminToken | 已完成 |
| 2026-03-17 | 修复 DistributionPage Order.description用户余额人工调整后端 adjust API + 用户详情入口);代付列表页(后端 gift-pay-requests + 推广中心 Tab | 已完成 |
| 2026-03-17 | 会议稳定版源码质量优化UserDetailModal 改 /api/admin/user/track、RichEditor HTML 转义 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化已落地;开发环境测试通过 | 已完成 |
| 2026-03-17 | 性能优化会议OSS 配置后上传自动优先 OSS失败回退本地无需前端改动 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-12
**最后更新**2026-03-17
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -0,0 +1,17 @@
# 2026-03-14 - 内容排行权重保存后刷新
## 问题 / 场景
- 管理端「排名算法」弹窗修改权重并保存后,内容排行榜列表和排序不更新。
- 原因:`handleSaveRankingWeights` 成功时只调用了 `loadList()`(章节树),未调用 `loadRanking()`(内容排行榜)。
## 解决方案
- 保存成功后:
1. `setShowRankingAlgorithmModal(false)` 关闭弹窗
2. `loadList()` 刷新章节树(含 hotScore
3. `loadRanking()` 刷新内容排行榜
## 代码位置
- `soul-admin/src/pages/content/ContentPage.tsx``handleSaveRankingWeights`

View File

@@ -0,0 +1,16 @@
# 管理端开发工程师 经验记录 - 2026-03-16
## 链接人与事列表优化
- **table 布局**:列表改用 `<table>` 替代 flex列头与数据对齐
- **新增列**planId、apiKey
- **apiKey 复制**:列前增加复制图标,点击复制到剪贴板并 toast 提示
- **删除确认**:用 Dialog 弹窗替代 confirm(),二次确认文案清晰
## 删除弹窗尺寸
- max-w-md、p-4、gap-3避免弹窗过大
## new-soul 派对AI 与管理端会议new-soul 新需求与当前项目差异分析)
- 派对AI 不新增管理端需求,管理端以当前项目为准

View File

@@ -0,0 +1,19 @@
# 管理端开发工程师 经验记录 - 2026-03-17
## 稳定版源码质量优化会议2026-03-17
- **UserDetailModal**:改为调用 `/api/admin/user/track`(后端新增后同步改)
- **RichEditor**@mention 弹窗对 `item.name``item.label` 做 HTML 转义,防 XSS不改 @mention 行为
---
## 会议收尾2026-03-17
- 源码优化已落地;开发环境测试通过
---
## 性能优化会议2026-03-17
- **OSS 上传**:系统设置保存 OSS 配置后,上传接口自动优先 OSS失败回退本地
- 无需前端改动,后端 upload handler 已支持

View File

@@ -3,6 +3,8 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计 | [2026-03-12.md](./2026-03-12.md) |
| 2026-03-14 | 排名算法权重保存后 loadRanking 刷新、关闭弹窗 | [2026-03-14.md](./2026-03-14.md) |
| 2026-03-05 | 分支合并后全功能自测404/异常接口记录 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-16 | 链接人与事table 布局、planId/apiKey 列、复制图标、删除 Dialog 弹窗 | [2026-03-16.md](./2026-03-16.md) |

View File

@@ -0,0 +1,103 @@
# 软件测试 经验记录 - 2026-03-16
## scripts 目录与测试关联
测试工程师需了解项目根目录 `scripts/` 下的辅助脚本,以便在联调、回归、环境准备时正确使用。
---
### 1. 本地启动脚本(联调必备)
| 脚本 | 用途 | 测试关联 |
|------|------|----------|
| `本地启动.sh` | 一键启动 soul-api8080+ soul-admin5174 | **三端联调前**:先执行此脚本,确保后端与管理端在本地运行;小程序需配置本地 API 地址 |
**用法**`./scripts/本地启动.sh``bash scripts/本地启动.sh`
**前置**Mac/Linux 环境soul-api 需能连接数据库;首次会编译 `soul-api-mac`
**验证**:访问 http://localhost:5174默认账号 admin / admin123
---
### 2. 飞书相关脚本(非核心业务,可了解)
| 脚本/目录 | 用途 | 测试关联 |
|-----------|------|----------|
| `feishu_export/` | 书稿导出 md供飞书知识库同步 | 与 Soul 三端业务无直接关系,回归可不覆盖 |
| `sync_book_to_feishu_export.py` | 从书稿目录导出 md 到 feishu_export | 同上 |
| `feishu_wiki_upload.py` | 上传全书到飞书知识库 | 同上 |
| `send_chapter_poster_to_feishu.py` | 生成章节海报并推送到飞书群 | 若海报含小程序码,可顺带验证二维码可访问性 |
---
### 3. Git 推送脚本
| 脚本 | 用途 | 测试关联 |
|------|------|----------|
| `gitea_push_once.sh` | 首次推送到 Gitea 仓库 | 与功能测试无关,部署/发布流程用 |
---
### 4. 测试工程师使用建议
- **联调前**:优先使用 `本地启动.sh` 启动后端与管理端,再测小程序、管理端功能
- **回归范围**scripts 内飞书、Gitea 脚本不纳入三端功能回归清单
- **环境依赖**`本地启动.sh` 依赖 Go 编译、pnpm、数据库可连测试环境需提前确认
---
### 5. 测试用例目录 `scripts/test/`(测试工程师主战场)
| 子目录 | 用途 | 对应端 |
|--------|------|--------|
| **miniapp/** | 小程序接口测试 | miniprogramAPI/api/miniprogram/* |
| **web/** | 管理端测试 | soul-adminAPI/api/admin/*、/api/db/* |
| **process/** | 流程测试 | 跨端,多接口串联 |
**约定**测试工程师在此编写与维护测试用例miniapp 放小程序接口、web 放管理端、process 放跨端业务流程。
**环境配置**必须明确指定测试环境SOUL_TEST_ENV=local|souldev|soulapi 或 SOUL_API_BASE运行前会打印「测试环境: xxx」横幅避免误测正式库。配置可来自 soul-api/.env* 或 scripts/test/.env.test。
---
### 6. pytest + requests 架构与配置约定
| 文件 | 说明 |
|------|------|
| config.py | 从项目 soul-api/.env* 或 .env.test 读取SOUL_TEST_ENV / SOUL_API_BASE |
| conftest.py | base_url、admin_token、miniapp_tokenpytest_report_header 显示环境横幅 |
| util.py | admin_headers、miniapp_headers |
| requirements-test.txt | pytest、requests |
**配置优先级**SOUL_TEST_ENV > SOUL_API_BASE > .env.test > soul-api/.env* > 默认 local。
**运行前必看**pytest 报告头部会显示「测试环境: 本地/测试/正式 (URL)」,确认无误后再执行。
---
### 7. 测试用例归档与复用规则
| 场景 | 归档目录 | 示例 |
|------|----------|------|
| 管理端 + 后端混合 | process/ | 文章 @某人 自动创建 Person + 存客宝 |
| 仅小程序接口 | miniapp/ | 登录、VIP、阅读 |
| 仅管理端/后端 | web/ | 鉴权、CRUD |
**需求变更**:用例随需求更新;无变更时直接复用。
---
### 8. 与 soul-api/scripts 的区别
| 位置 | 内容 | 测试关联 |
|------|------|----------|
| `scripts/`(项目根) | 本地启动、飞书同步、Gitea 推送、**test/** | 见上文 |
| `scripts/test/` | **测试用例**miniapp、web、processpytest 架构 | 测试工程师在此写用例 |
| `soul-api/scripts/` | SQL 迁移、Python 脚本等 | 数据库迁移、后端运维;测试时若涉及表结构变更,需关注对应 SQL |
---
### 9. 示例:文章 @某人 自动创建2026-03-16
- **用例**`scripts/test/process/test_article_mention_ckb_flow.py`
- **报告**`scripts/test/process/2026-03-16-文章@某人自动创建-测试报告.md`
- **结论**:后端逻辑正确,会调用存客宝创建计划;存客宝 API 返回 400 导致失败,需排查 CKB 配置或 deviceGroups 空值

View File

@@ -0,0 +1,7 @@
# 软件测试 经验记录 - 2026-03-16
## new-soul 派对AI 与测试关注点会议new-soul 新需求与当前项目差异分析)
- 引入派对AI 流程时需回归:文章上传、飞书推送、小程序展示
- 关注 content_upload.py 与 soul-api 数据流一致性,避免双写冲突
- 环境差异派对AI 为 Mac 路径,当前为 Windows需确认是否同一代码库不同环境

View File

@@ -0,0 +1,31 @@
# 软件测试 经验记录 - 2026-03-17
## 新版管理端迁移验收(会议:实施方案确认)
- **验收清单**:按《新版管理端迁移到稳定版-需求评估》§七
- **回归范围**:提现、分销、找伙伴、导师、设置等
- **风险**:合并时避免误覆盖稳定版独有逻辑,建议 diff 逐模块核对
- **三端联调**:管理端 ↔ soul-api 重点验证;用户规则、订单、余额展示
---
## 稳定版源码质量优化会议2026-03-17
- **回归重点**:支付流程、管理端用户详情行为轨迹、我的页找伙伴/推广/搜索、首页目录搜索
- **策略**:每项优化完成后小回归,全部完成后完整三端联调
---
## 会议收尾2026-03-17
- **功能测试流程定稿**`scripts/test/功能测试流程.md` — 成功 ☑️、失败列问题、最终报告
- **测试报告模板**`scripts/test/测试报告-环境与用例清单.md` — 环境、用例、结果记录
- **开发环境测试**10 通过、2 跳过、0 失败
---
## 性能优化会议2026-03-17
- **test_upload.py**6 个用例(上传成功、鉴权、校验、删除)
- **/health**:可验证 database、redis 连接状态
- **部署后回归**parts、hot、config、章节阅读等缓存接口

View File

@@ -2,7 +2,11 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-15 | **全站深度测试42个问题**管理端20+页面截图测试、35个API端点、25个小程序页面代码审查、25张数据库表严重11/高13/中12/低6沉淀安全测试/API测试/小程序测试/数据库测试方法论 | [2026-03-15-全站深度测试42问题.md](./2026-03-15-全站深度测试42问题.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-05 | 文章详情@某人@ 展示与添加好友用例、联调与回归点 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-16 | scripts 目录与测试关联:本地启动、飞书脚本、联调前环境准备 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | scripts/test 测试用例目录miniapp 小程序接口、web 管理端 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | scripts/test/process 流程测试:跨端多接口串联 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | 测试用例归档规则混合→process、纯端→miniapp/web需求变更时更新 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |

View File

@@ -0,0 +1,74 @@
# 正式版小程序「目录无法加载数据」排查分析
## 一、数据流梳理
| 页面/时机 | 接口 | 用途 |
|----------|------|------|
| App onLaunch | `GET /api/miniprogram/book/all-chapters` | 预加载全书章节到 globalData.bookData |
| 目录页 onLoad | `GET /api/miniprogram/book/parts` | **主接口**:懒加载篇章列表(不含章节详情) |
| 目录页展开篇章 | `GET /api/miniprogram/book/chapters-by-part?partId=xxx` | 按篇章拉取章节列表 |
**目录页展示依赖的是 `book/parts`**,不依赖 `all-chapters``all-chapters` 失败只会影响首页等处的预加载,目录页应能独立加载。
---
## 二、后端接口验证结果
运行 `SOUL_TEST_ENV=soulapi python scripts/test/check-catalog-api.py` 实测:
- **book/parts**:✅ 正常,返回 6 个篇章、90 个章节、5 个固定模块
- **all-chapters**:偶发 SSL 连接中断(大响应体时)
- **health**:偶发 SSL 握手超时
**结论**:正式环境 soulapi 的 `book/parts` 接口可用且数据正常,后端不是主要瓶颈。
---
## 三、可能原因与排查步骤
### 1. baseUrl 指向错误
- **现象**:正式版请求到 souldev 或 localhost
- **处理**`app.js` 中 baseUrl 改为注释切换方式,正式环境使用 `https://soulapi.quwanzhi.com`
### 2. 服务器域名未配置(优先排查)
- **现象**:正式版请求失败,开发工具勾选「不校验合法域名」时正常
- **处理**:微信公众平台 → 开发 → 开发管理 → 开发设置 → 服务器域名 → **request 合法域名**
- **必须包含**`https://soulapi.quwanzhi.com`
- **注意**:正式版、体验版都会校验,缺配置会导致请求被拦截
### 3. 正式环境数据库为空
- **现象**:接口返回 `parts: []``totalSections: 0`
- **排查**:执行诊断脚本,若 parts 为空则检查正式库 `chapters`
- **处理**:确认正式库已导入 `soul_miniprogram.sql` 及必要迁移脚本
### 4. SSL/网络不稳定
- **现象**:偶发连接中断、超时
- **排查**:多次调用诊断脚本,观察是否间歇失败
- **处理**:检查正式服务器 SSL 配置、反向代理、超时设置
### 5. 前端错误处理导致无提示
- **现象**:请求失败但用户只看到空白
- **代码**`chapters.js``loadParts` 在 catch 中 `setData({ bookData: [], totalSections: 0 })`,不弹窗
- **建议**:可在 catch 中增加 `wx.showToast({ title: '加载失败,请重试', icon: 'none' })` 便于用户感知
---
## 四、建议操作顺序
1. **确认 request 合法域名**:在微信公众平台添加 `https://soulapi.quwanzhi.com`
2. **本地验证接口**`SOUL_TEST_ENV=soulapi python scripts/test/check-catalog-api.py`
3. **正式版真机测试**:清除小程序缓存后重新打开,观察目录页是否加载
4. **若仍失败**:在 `chapters.js``loadParts` 中加 `console.log``wx.showModal` 输出错误信息,便于定位
---
## 五、相关文件
- 小程序:`miniprogram/app.js`baseUrl`miniprogram/pages/chapters/chapters.js`loadParts
- 后端:`soul-api/internal/handler/book.go`BookParts、BookChaptersByPart
- 诊断脚本:`scripts/test/check-catalog-api.py`

View File

@@ -0,0 +1,123 @@
# 会议纪要 - 2026-03-16 | new-soul 新需求与当前项目差异分析
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-16
- **议题**new-soul 新需求分析与当前项目 Mycontent 的差异
- **触发方式**:开会,所有人都参加,@new-soul 这是新需求
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
**定位差异**
- **new-soul 派对AI**:内容运营侧 AI 助手服务于《一场soul的创业实验》的派对→录屏→剪辑→成片→分发→文章→小程序全链路。
- **当前 Mycontent**:产品侧,面向创业者的社区/工具型小程序,核心是内容→会员→导师变现、存客宝对接、分销等。
**业务范围**
- 派对AI 覆盖运营报表、飞书管理、文章写作、视频剪辑、素材库上传、多平台分发、Soul 账号注册、小程序站管理9 技能 5 组)。
- 当前项目:三端代码 + 存客宝、链接人与事、VIP、分销等业务功能。
**结论**:两者是同一业务的不同层面(运营 vs 产品),非替代关系。
### 【后端开发】
**技术差异**
- 派对AI 依赖 `content_upload.py` 直连腾讯云 MySQL 写入 `soul_miniprogram.chapters`;路径为 Mac`/Users/karuo/...`)。
- 当前项目soul-apiGo/Gin/GORM通过 `/api/miniprogram/*``/api/admin/*``/api/db/*` 提供接口。
**重叠点**:小程序站管理技能引用的 soul-api、soul-admin、miniprogram 与当前三端一致;数据库同为 `soul_miniprogram`
**风险**`content_upload.py` 直连 DB 与 soul-api 并存,存在双写风险;路径为 Mac当前环境为 Windows需统一或适配。
### 【管理端开发工程师】
派对AI 未单独定义管理端功能,仅通过「小程序站管理」引用 soul-admin。当前项目管理端链接人与事、存客宝、VIP、推广中心等为完整实现。**结论**派对AI 不新增管理端需求。
### 【小程序开发工程师】
派对AI 的小程序站管理覆盖上传、部署、版本管理,与当前 miniprogram 一致。当前项目 C 端功能(文章阅读、@某人、存客宝留资、VIP、分销等已实现。**结论**派对AI 不改变小程序功能,仅涉及发布流程。
### 【测试人员】
1. **环境**派对AI 为 Mac 路径,当前为 Windows需确认是否同一代码库不同环境。
2. **数据流**`content_upload.py` 直写 DB vs soul-api 接口,需避免冲突。
3. **回归**若引入派对AI 流程,需回归文章上传、飞书推送、小程序展示。
---
## 讨论过程
- **产品经理 → 后端**`content_upload.py` 是否应逐步迁移到 soul-api 接口?
- **后端**:短期可保留直连,中长期规划为通过 soul-api admin/db 接口写入,统一数据入口。
- **小程序 → 产品**:需确认 chapters 表结构与 `content_upload.py` 写入格式一致。
- **后端**:需核对 `content_upload.py` 字段与 chapters 表、soul-api 模型一致性。
---
## 会议决议
1. **定位**new-soul 派对AI 为运营侧 AI 助手,当前 Mycontent 为产品侧三端项目,二者互补,非替代。
2. **三端**:小程序站管理技能与 soul-api、soul-admin、miniprogram 一致,无需调整三端代码。
3. **路径**派对AI 使用 Mac 路径,当前为 Windows需在文档或配置中说明环境差异或提供路径映射。
4. **数据**`content_upload.py` 直连 DB 与 soul-api 并存,需核对 chapters 表结构与字段一致性。
5. **待确认**`content_upload.py` 与 soul-api 的 chapters 模型是否完全一致?是否规划将文章上传迁移到 soul-api 接口?
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | 核对 content_upload.py 与 chapters 表/soul-api 模型一致性 | 中 | 2026-03-20 |
| 产品经理 | 确认文章上传是否规划迁移到 soul-api 接口 | 低 | 待定 |
| 助理橙子 | 在开发文档中补充 new-soul 与 Mycontent 关系说明 | 低 | 2026-03-18 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | content_upload.py 与 soul-api 的 chapters 模型是否完全一致? | 后端开发 | (待补充) |
| 2 | 是否规划将文章上传迁移到 soul-api 接口? | 产品经理 | (待补充) |
| 3 | new-soul 派对AI 与 Mycontent 是否为同一代码库、不同环境Mac vs Windows | 产品经理 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- new-soul 派对AI 与 Mycontent 为同一业务的不同层面:运营侧 vs 产品侧,互补非替代。
### 后端开发
- content_upload.py 直连 DB 与 soul-api 并存,需核对 chapters 表结构;中长期可规划迁移到 soul-api 接口。
### 管理端开发工程师
- 派对AI 不新增管理端需求,管理端以当前项目为准。
### 小程序开发工程师
- 派对AI 的小程序站管理与当前 miniprogram 一致,仅涉及发布流程。
### 测试人员
- 引入派对AI 流程时需回归:文章上传、飞书推送、小程序展示;关注 content_upload.py 与 soul-api 数据流一致性。
### 团队共享
- new-soul 派对AI魂AI9 技能 5 组:魂资/魂流/魂产/魂码/魂质;与 Mycontent 三端soul-api、soul-admin、miniprogram为同一业务不同层面路径差异Mac vs Windows需在文档中说明。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-16.md`*

View File

@@ -0,0 +1,85 @@
# 会议纪要 - 2026-03-16 | 链接人与事与存客宝对接优化
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-16
- **议题**:链接人与事列表优化、存客宝对接参数、@mention 显示异常修复
- **触发方式**:会议结束,总结会议
- **参与角色**:管理端开发工程师、后端工程师、团队
---
## 各角色发言
> 本次为开发会话总结,非正式多角色会议。按完成事项归类。
### 【管理端开发工程师】
- 链接人与事列表改为 `<table>` 布局,解决列头与数据对齐问题
- 新增 planId、apiKey 两列展示
- apiKey 列增加复制图标,点击复制到剪贴板
- 删除操作改为 Dialog 二次确认弹窗,替代原生 confirm
- 弹窗尺寸优化max-w-md、p-4、gap-3避免过大
### 【后端工程师】
- 存客宝创建计划参数调整planType=1、sceneId=9、scenario=9、status=1
- ParseAutoLinkContent 输出 mention span 时增加 `data-label`,修复 TipTap 显示 token 而非名字的问题
- 已损坏内容span 内为 token自动修复用 token 查 persons 取真实名字补回 data-label
### 【团队共享】
- TipTap Mention 需 `data-label` 属性:仅从 data-label 解析显示名,缺则回退显示 data-idtoken
---
## 讨论过程
(开发会话,用户逐项提出需求并实现)
---
## 会议决议
1. **链接人与事列表**:使用 table 布局,展示 token、@的人、获客计划活动名、planId、apiKey、操作
2. **存客宝创建计划**planType=1、sceneId=9、status=1
3. **@mention 存储格式**span 必须含 `data-label` 存显示名,否则 TipTap 会显示 token
---
## 待办事项
(无待办,原待确认项已确认为代码 bug 并修复)
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | CKBLead 接口 401 无效的 apiKey | 后端 | **已修复**(代码 bug通过 planType/sceneId/status 等参数修正) |
| 2 | sceneId=9 与 planType=1 在存客宝业务含义 | 后端 | **已修复**(代码已按 planType=1、sceneId=9、status=1 实现) |
---
## 各角色经验与业务理解更新
### 管理端开发工程师
- 链接人与事列表用 table 布局apiKey 列需复制图标;删除用 Dialog 二次确认
### 后端工程师
- ParseAutoLinkContent 输出 mention 必须含 data-label存客宝 create 需 planType=1 sceneId=9 status=1已损坏 mention 可查 persons 补回 label
### 团队共享
- TipTap Mention 的 label 仅从 data-label 解析,不解析 span 内文本
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-16.md`*

View File

@@ -0,0 +1,90 @@
# 会议收尾 - 2026-03-17 | 源码优化完成与测试流程定稿
> 本文件由**助理橙子**在会议结束后自动生成。对应会议2026-03-17 稳定版源码质量优化方案讨论与开发安排。
---
## 基本信息
- **时间**2026-03-17
- **议题**:会议收尾 — 源码质量优化完成、开发环境测试、功能测试流程定稿、开发文档同步
- **触发方式**:结束会议、沉淀经验、开发部门同步需求
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员、助理橙子
---
## 收尾结论
### 1. 源码质量优化10 项全部完成)
| 端 | 任务 | 状态 |
|----|------|------|
| 后端 | 敏感配置生产环境强制校验config.go | ☑️ 已完成 |
| 后端 | 新增 GET /api/admin/user/track + AdminAuth | ☑️ 已完成 |
| 后端 | AdminWithdrawTest 环境限制 | ☑️ 已完成 |
| 管理端 | UserDetailModal 改为 /api/admin/user/track | ☑️ 已完成 |
| 管理端 | RichEditor name/label HTML 转义 | ☑️ 已完成 |
| 小程序 | 删除 payment.js | ☑️ 已完成 |
| 小程序 | 删除 goToMatch 重复定义 | ☑️ 已完成 |
| 小程序 | 删除 read.js.backup、referral.wxss.backup | ☑️ 已完成 |
| 小程序 | appId 等从 config 读取 | ☑️ 已完成 |
| 小程序 | totalSections 动态获取 | ☑️ 已完成 |
### 2. 开发环境测试
- **环境**local (http://localhost:8080)
- **结果**10 通过、2 跳过、0 失败
- **跳过**test_dev_login_as需 SOUL_MINIPROGRAM_DEV_USER_ID、test_backfill_persons_ckb_api_key需 CKB 配置)
### 3. 测试流程与文档
| 产出 | 路径 | 说明 |
|------|------|------|
| 功能测试流程 | scripts/test/功能测试流程.md | 环境准备→自动化→手工验证→问题汇总→报告;成功 ☑️,失败列问题 |
| 测试报告模板 | scripts/test/测试报告-环境与用例清单.md | 环境、用例清单、结果记录、归档说明 |
### 4. 开发文档同步
- **运营与变更**:新增第十七部分「源码优化完成与测试流程定稿」
- **需求汇总**:源码优化为内部质量项,无新增需求条目
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 生产环境判断:是否以 MODE=release 为准? | 后端开发 | (待补充) |
| 2 | dev/login-as 后端限制方案:环境变量 or IP 白名单? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 源码质量优化验收10 项全部完成,功能不变;测试流程与报告模板已定稿
### 后端开发
- 源码优化已落地config 生产校验、admin/user/track、AdminWithdrawTest 环境限制
### 管理端开发工程师
- UserDetailModal、RichEditor 优化已落地;开发环境测试通过
### 小程序开发工程师
- payment.js 删除、goToMatch 去重、备份清理、config 读取、totalSections 动态化已落地
### 测试人员
- 功能测试流程定稿:☑️ 成功、失败列问题、最终报告;开发环境 10 通过 2 跳过
### 助理橙子
- 会议收尾:纪要、经验入库、项目索引、会议索引、开发文档同步
---
*会议收尾由助理橙子执行 | 各角色经验已同步至 agent/{角色}/evolution/2026-03-17.md*

View File

@@ -0,0 +1,101 @@
# 会议纪要 - 2026-03-17 | 性能优化与 Redis 缓存方案落地
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-17
- **议题**三端性能优化、Redis 缓存接入、OSS 上传、健康检查增强
- **参与角色**:后端开发、管理端开发工程师、小程序开发工程师、测试人员、助理橙子
---
## 会议决议
### 1. Redis 缓存接入(已完成)
| 接口 | 路由 | TTL | 失效触发 |
|------|------|-----|----------|
| 目录 | `/api/miniprogram/book/parts` | 10min | 章节增删改 |
| 热门 | `/api/miniprogram/book/hot` | 5min | 章节更新 |
| 推荐 | `/api/miniprogram/book/recommended` | 5min | 章节更新 |
| 统计 | `/api/miniprogram/book/stats` | 5min | 章节更新 |
| 配置 | `/api/miniprogram/config` | 10min | 配置变更 |
| 正文 | `/api/book/chapter/by-mid/:mid` | 30min | 章节内容更新 |
### 2. Redis 容灾
- Redis 未配置或连接失败时自动回退 DB不阻塞业务
- 读写失败仅打日志,不向上抛出
### 3. OSS 上传接入(已完成)
- 管理端图片上传支持阿里云 OSS
- 未配置或失败时回退本地磁盘
- 删除支持 OSS URL 与本地路径
### 4. /health 接口增强(已完成)
- 返回 `database``redis` 连接状态ok / disconnected / disabled
### 5. Redis 配置
- `.env.production``.env.development` 增加 REDIS_URL
- 服务器 Redis端口 6379密码 ckb@!URL 编码 ckb%40%21
### 6. 迁移功能变更清单2026-03-17
1. Redis 缓存接入:目录、热门/推荐/统计、config、章节正文
2. Redis 容灾:未配置或失败时回退 DB
3. OSS 上传接入:管理端图片支持阿里云 OSS容灾回退本地
4. /health 接口增强:返回 database、redis 连接状态
5. Redis 配置:.env 增加 REDIS_URL服务器密码 ckb@!
6. 文件上传测试:新增 test_upload.py 共 6 个用例
7. 缓存失效策略:章节/内容/配置变更时自动失效对应 Redis 缓存
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | 部署后验证 Redis 连接(/health 显示 redis: ok | 中 | 部署后 |
| 测试人员 | 部署后回归缓存接口parts、hot、config、章节阅读 | 中 | 部署后 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | Redis 与 soul-api 跨机部署时REDIS_URL 中 host 填 Redis 服务器 IP 是否已文档化? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 后端开发
- internal/cache 包Get/Set/Del、GetString/SetStringKeyBookParts、KeyChapterContent 等
- 缓存失效InvalidateBookParts、InvalidateBookCache、InvalidateConfig、InvalidateChapterContent
- 章节正文缓存:先查元数据(不含 content再取 contentRedis 或 DB
### 管理端开发工程师
- OSS 配置在系统设置保存后,上传接口自动优先 OSS无需前端改动
### 测试人员
- test_upload.py6 个用例覆盖上传成功、鉴权、校验、删除
- /health 可验证 database、redis 连接状态
### 团队共享
- Redis 容灾约定:不可用时回退 DB不阻塞业务
- 缓存 key 规范soul:{业务}:{标识}
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-17.md`*

View File

@@ -0,0 +1,106 @@
# 会议纪要 - 2026-03-17 | 新版管理端迁移到稳定版实施方案确认
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-17
- **议题**:新版管理端迁移到稳定版 - 确认实施方案
- **触发方式**:乘风调动开发人员开会,并确认实施方案
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
需求基准以稳定版小程序为准内容管理以稳定版为主验收标准见《需求评估》§七RFM、journey、神射手是否保留需运营确认。
### 【后端开发】
以稳定版为主,后端无需新增接口;若保留 RFM/journey/神射手需补 5 个 routerOSS 需确认 /api/admin/settings 是否支持 ossConfig建议先补 router 再迁移。
### 【管理端开发工程师】
内容管理以稳定版为准不覆盖;必须保留用户详情余额、订单支付方式/代付、RechargeAlert、LinkedMp 等可吸纳编辑禁用、鉴权、API 文档、OSS按模块分批合并。
### 【小程序开发工程师】
本次主要影响管理端,小程序无需改动;迁移后做三端联调验证用户规则、订单、余额。
### 【测试人员】
按《需求评估》§七验收;回归提现、分销、找伙伴等;合并时避免误覆盖稳定版独有逻辑。
---
## 讨论过程
- 乘风确认:后端 router 补齐建议迁移前完成
- 产品经理OSS 按实际部署需求,用户决议「新版有的就迁移」→ OSS 纳入
- 管理端:同意 OSS 纳入,新版独有能力全部吸纳
---
## 会议决议
1. **新版有的就迁移**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑优化,全部吸纳到稳定版
2. **内容管理**:以稳定版为主,不采纳新版
3. **后端 router**:迁移前补齐 users/rfm、users/journey-stats、shensheshou 共 5 个路由(若运营使用)
4. **实施顺序**Phase 0 后端补 router → Phase 1 基础模块 → Phase 2 业务模块 → Phase 3 内容保持 → Phase 4 验收
---
## 待办事项(乘风指派)
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | soul-api router 注册 users/rfm、users/journey-stats、shensheshou 共 5 个路由 | 高 | 迁移前 |
| 后端开发 | 确认 /api/admin/settings 是否支持 ossConfig若不支持则补充 | 中 | 迁移前 |
| 管理端开发工程师 | 从 new-soul/soul-admin 迁移ApiDocsPage、OSS 配置、api-docs 路由、编辑时手机号禁用、鉴权逻辑 | 高 | - |
| 管理端开发工程师 | 以稳定版为基准合并,内容管理不覆盖,其他模块选择性合并 | 高 | - |
| 测试人员 | 迁移完成后按《需求评估》§七执行验收,三端联调 | 中 | 迁移后 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | RFM、用户旅程、神射手是否继续使用 | 产品/运营 | (待补充) |
| 2 | /api/admin/settings 是否已支持 ossConfig | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 新版管理端迁移以稳定版为基准内容管理以稳定版为主新版独有能力API 文档、OSS、编辑禁用、鉴权全部吸纳
### 后端开发
- 迁移前需补 routerusers/rfm、users/journey-stats、shensheshou 共 5 个;需确认 settings 的 ossConfig 支持
### 管理端开发工程师
- 迁移策略:内容管理不覆盖;其他模块以稳定版为主;吸纳新版 ApiDocsPage、OSS、编辑禁用、鉴权按模块分批合并
### 小程序开发工程师
- 管理端迁移不影响小程序;迁移后做三端联调验证
### 测试人员
- 验收按《需求评估》§七;合并时需 diff 核对避免误覆盖
### 团队共享
- 新版管理端迁移到稳定版:内容管理以稳定版为主,新版独有能力全部吸纳;详见 agent/团队/evolution/2026-03-17.md
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-17.md`*

View File

@@ -0,0 +1,113 @@
# 会议纪要 - 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-17
- **议题**:稳定版源码质量优化方案讨论与开发安排(基于源码质量分析报告,不影响现有功能)
- **触发方式**:开会讨论
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
从需求与业务角度,本次优化聚焦**安全与可维护性**,不涉及新功能,用户无感知。建议按优先级分批处理:高优(安全)→ 中优(代码质量)→ 低优(性能/结构)。验收标准:优化后现有功能行为不变,三端联调通过。
### 【后端开发】
高优1敏感配置在 `config.go`生产环境MODE=release强制校验缺则 Fatal2新增 `GET /api/admin/user/track` 并加 AdminAuth`/api/user/track` 保留给小程序 POST 埋点。中优AdminWithdrawTest 加环境限制。低优DBUsersList 拆分可后续做。
### 【管理端开发工程师】
中优1后端提供 `/api/admin/user/track`UserDetailModal 改为调用新路径2RichEditor 对 name/label 做 HTML 转义防 XSS。低优上传逻辑抽公共方法可后续做。
### 【小程序开发工程师】
高优1payment.js 确认无引用可删除2goToMatch 重复定义删除一个3删除 read.js.backup、referral.wxss.backup。中优appId 等从 config 读取、totalSections 动态获取。dev/login-as 由后端限制即可。
### 【测试人员】
回归重点:支付流程、管理端用户详情行为轨迹、我的页找伙伴/推广/搜索、首页目录搜索。建议每项完成后小回归,全部完成后完整三端联调。
---
## 讨论过程
- 后端确认 `/api/admin/user/track` 参数与现 `/api/user/track` 一致userId、phone、limit管理端仅改 URL 即可
- 小程序确认 payment.js 无 require 引用,可安全删除
- 产品确认按优先级分批,低优可放入后续迭代
---
## 会议决议
1. **高优(本周完成)**:后端敏感配置生产强制校验、新增 `/api/admin/user/track`;管理端 UserDetailModal 改路径;小程序删除 payment.js、goToMatch 重复、备份文件
2. **中优(下周完成)**:后端 AdminWithdrawTest 环境限制;管理端 RichEditor 转义;小程序 config 读取、totalSections 动态化
3. **低优(后续迭代)**DBUsersList 拆分、上传逻辑抽公共、config 缓存
4. **原则**:所有改动为增量修复,不改现有功能逻辑;每项完成后小回归,全部完成后完整联调
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | 敏感配置生产环境强制校验config.go | 高 | 本周 |
| 后端开发 | 新增 GET /api/admin/user/track + AdminAuth | 高 | 本周 |
| 后端开发 | AdminWithdrawTest 环境限制 | 中 | 下周 |
| 管理端开发工程师 | UserDetailModal 改为 /api/admin/user/track | 高 | 本周 |
| 管理端开发工程师 | RichEditor name/label HTML 转义 | 中 | 下周 |
| 小程序开发工程师 | 删除 payment.js | 高 | 本周 |
| 小程序开发工程师 | 删除 goToMatch 重复定义 | 高 | 本周 |
| 小程序开发工程师 | 删除 read.js.backup、referral.wxss.backup | 高 | 本周 |
| 小程序开发工程师 | appId 等从 config 读取 | 中 | 下周 |
| 小程序开发工程师 | totalSections 动态获取 | 中 | 下周 |
| 测试人员 | 每项完成后小回归 | - | 持续 |
| 测试人员 | 全部完成后三端联调 | - | 下周 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 生产环境判断:是否以 MODE=release 为准? | 后端开发 | (待补充) |
| 2 | dev/login-as 后端限制方案:环境变量 or IP 白名单? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 源码质量优化按安全→可维护→性能分批,验收标准为功能不变、三端联调通过
### 后端开发
- 敏感配置生产环境缺则 Fataluser/track 查询迁至 admin 组并加鉴权AdminWithdrawTest 非 develop 拒绝
### 管理端开发工程师
- UserDetailModal 调用 /api/admin/user/trackRichEditor @mention 需对 name/label 做 HTML 转义防 XSS
### 小程序开发工程师
- payment.js 废弃可删goToMatch 去重备份文件清理appId 等优先从 config 读取totalSections 动态获取
### 测试人员
- 源码优化类改动需每项小回归 + 全部完成后完整三端联调,重点覆盖支付、用户详情、我的页、搜索
### 团队共享
- 源码质量优化原则:增量修复、不改功能逻辑;高优安全项优先,中优可维护项次之,低优可后续迭代
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-17.md`*

View File

@@ -73,3 +73,9 @@ YYYY-MM-DD_会议主题.md
| 2026-03-10 | 文章详情三端功能对齐与开发(@mention/#linkTag/图片 | 产品、后端、管理端、小程序 | [2026-03-10_文章详情三端功能对齐与开发.md](2026-03-10_文章详情三端功能对齐与开发.md) |
| 2026-03-10 | Toast 通知系统全局落地 & hot_score 数据库迁移 | 管理端、后端、团队 | [2026-03-10_Toast通知系统全局落地.md](2026-03-10_Toast通知系统全局落地.md) |
| 2026-03-11 | 开发团队对齐业务逻辑与以界面定需求·会议收尾 | 产品、后端、管理端、小程序、团队 | [2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md](2026-03-11_开发团队对齐业务逻辑与以界面定需求会议收尾.md) |
| 2026-03-16 | 链接人与事与存客宝对接优化 | 管理端、后端、团队 | [2026-03-16_链接人与事与存客宝对接优化.md](2026-03-16_链接人与事与存客宝对接优化.md) |
| 2026-03-16 | new-soul 新需求与当前项目差异分析 | 产品、后端、管理端、小程序、测试 | [2026-03-16_new-soul新需求与当前项目差异分析.md](2026-03-16_new-soul新需求与当前项目差异分析.md) |
| 2026-03-17 | 新版管理端迁移到稳定版实施方案确认 | 产品、后端、管理端、小程序、测试 | [2026-03-17_新版管理端迁移到稳定版实施方案确认.md](2026-03-17_新版管理端迁移到稳定版实施方案确认.md) |
| 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排 | 产品、后端、管理端、小程序、测试 | [2026-03-17_稳定版源码质量优化方案讨论与开发安排.md](2026-03-17_稳定版源码质量优化方案讨论与开发安排.md) |
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿 | 产品、后端、管理端、小程序、测试、助理橙子 | [2026-03-17_会议收尾-源码优化完成与测试流程定稿.md](2026-03-17_会议收尾-源码优化完成与测试流程定稿.md) |
| 2026-03-17 | 性能优化与 Redis 缓存方案落地 | 后端、管理端、小程序、测试、助理橙子 | [2026-03-17_性能优化与Redis缓存方案落地.md](2026-03-17_性能优化与Redis缓存方案落地.md) |

View File

@@ -1,7 +1,7 @@
---
description: Soul 创业派对开发团队多角色会议触发器。开个会、团队会议、需求评审、方案讨论时加载 SKILL-团队会议
globs: ["**"]
alwaysApply: false
alwaysApply: true
---
# Soul 创业派对 - 会议触发器

View File

@@ -15,9 +15,10 @@ alwaysApply: true
| 子项目 | 目录 | 用途 | 后端对接 |
|--------|------|------|----------|
| 小程序 | miniprogram/ | 微信原生小程序 C 端 | soul-api |
| 管理端 | soul-admin/ | React 管理后台 | soul-api |
| 管理端 | soul-admin/ | React 管理后台(稳定版,主用) | soul-api |
| API 后端 | soul-api/ | Go + Gin + GORM 接口服务 | - |
| 预览/参考 | next-project/ | 仅预览,非线上 | 不依赖 |
| **新版管理端** | **new-soul/soul-admin/** | 新版参考实现,迁移时对照 | soul-api |
## 核心原则
@@ -60,5 +61,7 @@ alwaysApply: true
| 变更完成、检查一下、准备提交 | `e:\Gongsi\Mycontent\.cursor\skills\change-checklist\SKILL.md` |
| 开个会、开会、团队会议、乘风开会、需求评审、方案讨论、大家一起讨论 | `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md`(老板分身/乘风主持) |
| 会议结束、散会、会开完了 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md`(会议收尾) |
| **加个需求**、加个需求xxx | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md`(产品经理三端分析 → 功能规划 → 指派) |
| **新版分析**、版本对比、迁移分析、甲方代码分析、快速分析新版、抽取需求 | `e:\Gongsi\Mycontent\.cursor\skills\new-version-analyze\SKILL.md`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
**注意**:「必须 Read」= 使用 Read 工具读取**绝对路径**的完整文件内容后执行,不可跳过或仅凭记忆。

View File

@@ -6,7 +6,7 @@ alwaysApply: true
# 老板分身 - 能力与约束Soul 创业派对)
> **老板分身权限最高**:协调所有智能体(小程序开发工程师、管理端开发工程师、后端工程师、产品经理、开发助理等)。其他 agent 执行任务时遵循本规则;老板分身可调度、协调、指派任一角色。
> **激活方式**:用户说「老板」「分身」「乘风」「架构」「帮我协调」时,从旁观者转为主动参与。**开会时**:用户说「开会」「开个会」「团队会议」「乘风开会」等,老板分身(乘风)作为主持人自动读取并执行 `.cursor/skills/team-meeting/SKILL.md` 中的会议协议
> **激活方式**:用户说「老板」「分身」「乘风」「架构」「帮我协调」时,从旁观者转为主动参与。**开会时**:用户说「开会」「开个会」「团队会议」「乘风开会」「需求评审」「方案讨论」等表达开会意图时,**必须先**用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md` 完整内容,然后由老板分身(乘风)按该协议主持多角色会议,不可仅回复而不执行流程
> **会话自检**:仅沿用本项目 `.cursor/` 下的 rules、skills、agent忽略与本项目无关的全局 rules/skills。
> **角色驱动**Soul 角色与 agent 映射见 `config/paths.py` 的 ROLE_TO_AGENT。

View File

@@ -76,12 +76,43 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
---
## 8. 何时使用本 Skill
## 8. 平台合规与能力检测2025 起)
- **隐私按需授权**:涉及用户信息的接口(登录、手机号、位置等)必须在用户**实际触发功能时**再请求授权,禁止在 `app.onLaunch` 中集中请求。需配置《小程序用户隐私保护指引》,使用 `<button open-type="agreePrivacyAuthorization">` 获取同意。
- **能力检测**:使用新 API 前用 `wx.canIUse('api.xxx')` 检测,低版本做降级。
- **Skyline可选**:性能敏感页面可在 `page.json` 中配置 `"renderer": "skyline"`,仍使用 WXML/WXSS。
---
## 9. 文本可选与复制(阅读类内容)
- **长按选中复制**:需支持长按选中的文本用 `<text user-select>...</text>`(基础库 2.12.1+`selectable` 已废弃)。
- **适用**:章节标题、正文段落、@ 提及、# 链接标签、预览内容等。
- **注意**`user-select` 会使 text 显示为 inline-block若布局异常可回退 `selectable`iOS 原生选中失效时可用 `bindlongpress` + `wx.setClipboardData` 做整段复制兜底。
---
## 10. 分享名片(编辑资料页)
- **场景**:编辑资料页转发/朋友圈做特殊处理,直接变成分享名片。
- **标题**`昵称+为您分享名片`(如「少年梦想家为您分享名片」)。
- **封面**Canvas 绘制 5:4 封面图,布局:左头像(圆形)+ 右昵称 +「个人名片」副标题 + 分隔线 + 四栏信息地区、MBTI、行业、职位
- **路径**`member-detail?id=userId`,好友打开即见名片详情。
- **预生成**:资料加载完成后、头像更新后调用 `generateShareCard()`,将 `shareCardPath` 存入 data`onShareAppMessage`/`onShareTimeline` 返回 `imageUrl: shareCardPath`
- **朋友圈重定向**:分享带 `id` 时,`profile-edit``onLoad` 检测 `options.id``redirectTo``member-detail`
- **头像**:网络头像需 `wx.downloadFile`,域名须配置 downloadFile 合法域名;失败时用昵称首字母占位。
---
## 11. 何时使用本 Skill
-**miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
- 做登录、手机号、推荐码等涉及用户信息的授权时(遵循 §8 隐私按需授权)。
- 做表单、input/textarea 样式时(遵循 §6用 view 包裹padding 写在 view 上)。
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
- 做阅读、文章等需长按复制的文本时(遵循 §9text 加 user-select
- 做编辑资料页分享名片时(遵循 §10
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -0,0 +1,284 @@
---
description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本逻辑不完善、接口不规范、存在逻辑冲突时使用。快速分析、体验评估、逻辑补齐、抽取需求在稳定版迭代。Use when 新版分析、版本对比、迁移分析、甲方代码分析、快速分析新版、new-soul 分析.
---
# SKILL - 新版快速分析(甲方代码迁移)
> 针对「甲方/第三方用 AI 写的新版本,逻辑不完善、接口不规范、存在逻辑冲突」的场景,快速分析、抽取需求、在稳定版迭代。
---
## 1. 何时使用本 Skill
| 触发词 | 场景 |
|--------|------|
| **新版分析**、**版本对比**、**迁移分析** | 需要系统对比 new-soul 与稳定版 |
| **甲方代码分析**、**快速分析新版** | 对方改了代码,逻辑不完善、接口不规范 |
| **@new-soul 分析**、**抽取需求** | 从新版抽取需求,在稳定版更新迭代 |
**典型困境**
- 对方有改接口,但不符合规范(路由、响应格式、鉴权)
- 存在逻辑冲突(如余额消费不写 orders、分润规则不统一
- 纯界面需求变更,底层逻辑未考虑
- **稳定版也有新增功能,甲方版本并未更新** → 功能对齐与取舍需明确
- 需要在短时间内补齐完整逻辑
---
## 2. 核心原则
| 原则 | 说明 |
|------|------|
| **稳定版为基准** | 稳定版是生产环境,逻辑已验证;新版只做增量补齐 |
| **接口不以新版为准** | 新版接口编写必然很多没考虑(路由、鉴权、事务、分润、幂等)。**接口设计必须以稳定版规范为准**,按 api-dev SKILL 重新设计,不照搬新版实现 |
| **界面当需求,逻辑自己写** | 对方界面/交互可参考,业务逻辑按规范重写 |
| **先分析再实现** | 先出清单、方案、冲突表,再写代码 |
| **规范优先** | 接口不符合规范的一律按 api-dev/miniprogram-dev 修正 |
| **最小功能迁移** | 按最小功能单元迁移,**每个功能迁移后都能完整运行**;界面修改可先迁,大逻辑排后 |
**接口处理方式**:新版接口仅作「能力参考」(知道要什么),实现时在稳定版 soul-api 中**按规范从零设计**,不直接复用新版代码。
---
## 2.5 保护区域(迁移时禁止动或慎动)
**以下模块是稳定版核心逻辑,新需求迁移时务必谨慎,不得影响:**
| 保护区域 | 说明 | 处理方式 |
|----------|------|----------|
| **文章详情 @/# 标签** | @某人#链接标签、contentParser、onMentionTap、onLinkTagTap、存客宝对接 | **禁止动**。迁移时不得修改 read 页的 @/# 解析与点击逻辑 |
| **分销** | 推荐码绑定、分润计算、referral 相关接口与订单关联 | **禁止动**。新增功能(如余额消费)若涉及购买,必须与分销逻辑兼容,不得覆盖或冲突 |
| **支付** | 微信支付、pay 接口、PayNotify 回调、订单创建与状态 | **禁止动**。余额支付等新增支付方式需在现有支付流程上**扩展**,不得替换或破坏原有逻辑 |
**迁移前检查**:若新版改动涉及 read 页正文、contentParser、ckb/lead、referral、pay、orders必须**逐行对比**,确认不覆盖保护区域。有冲突时以稳定版为准。
---
## 2.6 迁移前必做:需求评审
**迁移前必须先做需求评审,评审通过后再开始迁移。**
| 步骤 | 动作 | 产出 |
|------|------|------|
| 1 | 需求评审 | 召集评审,明确迁移范围与优先级 |
| 2 | 列出功能点 | 逐项列出:新增功能、修改功能、移除功能 |
| 3 | 列出样式变更 | 逐项列出:布局、配色、文案、交互变化 |
| 4 | 逐一确认 | 每个功能点、每项样式变更经确认后再迁 |
| 5 | 开始迁移 | 评审通过后,按确认清单逐项迁移 |
**产出文档**`开发文档/新版迁移-需求评审清单.md`(功能点 + 样式变更表,含确认状态)
---
## 2.7 迁移顺序(最小功能 + 可运行优先)
| 顺序 | 类型 | 说明 |
|------|------|------|
| **先迁** | 界面修改 | 纯 WXML/WXSS/布局、文案、样式,不涉及新接口或新逻辑 → 可直接迁移 |
| **后迁** | 大逻辑 | 涉及新接口、DB、事务、支付、分润等 → 排后,逐个迁移 |
**原则**
- 按**最小功能**拆:一个功能 = 一个可独立运行、可验证的单元
- 每次迁移后**必须能完整运行**:迁完即测,不积压半成品
- 界面优先:先迁界面类,快速见效;大逻辑逐个排期,降低风险
---
## 3. 功能对齐与取舍(必做)
**背景**:稳定版有新增功能,甲方版本未同步;甲方版本也有新功能。需先做**双向对齐**,再决定取舍。
### 3.0 功能三向分类
| 分类 | 说明 | 取舍原则 |
|------|------|----------|
| **仅稳定版有** | 你已开发,甲方未更新 | **保留**,不因迁移而删除 |
| **仅新版有** | 甲方新增,稳定版无 | 按需求评估:有价值则迁(界面当需求,逻辑重写);无价值则弃 |
| **两者共有** | 同一功能,实现不同 | **以稳定版为准**;若新版交互更好,可只迁界面/交互,逻辑仍用稳定版 |
### 3.0.1 取舍决策表(产出)
| 功能 | 分类 | 取舍 | 理由 |
|------|------|------|------|
| 链接人与事 | 仅稳定版有 | 保留 | 已上线,甲方无 |
| 钱包/余额 | 仅新版有 | 迁移 | 有业务价值,按规范重写 |
| 首页精选展开 | 两者共有 | 迁界面、逻辑用稳定版 | 新版交互可参考,数据源用 book/recommended 或 hot |
**产出**:在功能差异清单中增加「分类」「取舍」「理由」三列。
---
## 4. 分析流程(五步)
### 4.1 第一步快速摸底12 小时)
**动作****双向对比** new-soul 与稳定版,建立差异清单(含功能三向分类)
| 对比维度 | 产出 |
|----------|------|
| **页面** | 新增/移除/改版页面列表 |
| **接口** | 新增/修改/删除的 API 列表 |
| **字段** | 请求/响应/DB 字段差异 |
| **功能分类** | 仅稳定版有 / 仅新版有 / 两者共有 |
| **标注** | 每个变更:✅ 可用 / ⚠️ 不完整 / ❌ 逻辑错误 |
**产出文档**`开发文档/新版迁移-功能差异清单.md`(可复用已有迁移文档补充)
---
### 4.2 第二步:逻辑分层检查
对每个功能按三层过一遍,避免漏改:
```
┌─────────────────────────────────────┐
│ 界面层WXML/WXSS/JS、事件、数据绑定 │ ← 对方常只改这里
├─────────────────────────────────────┤
│ 接口层:调哪个 API、入参、返回、错误 │ ← 易漏:参数、鉴权、响应格式
├─────────────────────────────────────┤
│ 数据层DB 表、字段、事务、幂等 │ ← 易漏orders、分润、状态机
└─────────────────────────────────────┘
```
**检查项**
- 界面改了,接口是否已提供且规范?
- 接口改了DB/事务是否已同步?
- 是否有逻辑冲突(如 consume 不写 orders
---
### 4.3 第三步:体验评估
| 维度 | 检查点 |
|------|--------|
| **交互** | 加载态、空态、错误态是否完整 |
| **反馈** | 操作成功/失败是否有提示 |
| **边界** | 未登录、余额不足、网络失败等处理 |
| **一致性** | 与稳定版其他页面的风格、文案是否统一 |
**产出**:在差异清单中增加「体验评估」列
---
### 4.4 第四步:接口规范与冲突清单
**默认立场**:新版接口**不照搬**。从新版只提取「需要什么能力」,在稳定版按 api-dev 规范**重新设计**。
**接口规范检查**(以 api-dev SKILL 为准):
| 维度 | 稳定版约定 | 检查 |
|------|------------|------|
| 路由分组 | miniprogram / admin / db | 小程序是否只调 miniprogram |
| 响应格式 | `{ success, data, message }` | 是否统一 |
| 鉴权 | Bearer token、openId | 是否按约定 |
| 命名 | REST、资源名 | 是否统一 |
**逻辑冲突识别**
| 冲突类型 | 示例 | 处理原则 |
|----------|------|----------|
| 数据不一致 | 余额扣了不写 orders | 同一事务内补齐 |
| 规则不统一 | 微信支付有分润、余额消费没有 | 统一规则 |
| 字段语义冲突 | 同一字段不同含义 | 定死语义,全项目统一 |
| 幂等缺失 | 回调重复执行 | 加幂等(订单号去重) |
**产出文档**`开发文档/新版迁移-接口规范与冲突清单.md`
---
### 4.5 第五步:抽取需求,排期迭代
**动作**
1. 从差异清单中抽取「可迁移需求」
2. 排除:技术债、规则不清、与稳定版冲突的部分
3. 按**最小功能**拆分,保证每个任务迁移后能完整运行
4. 排期顺序:**界面修改优先** → 大逻辑排后P0逻辑不通→ P1功能缺失→ P2优化
5. 写入需求汇总,形成迁移任务清单
**产出**
- `开发文档/1、需求/需求汇总.md` 追加需求
- `开发文档/新版迁移-开发方案与清单.md` 或等价迁移清单
---
## 5. 产出物模板
### 5.1 功能差异清单(表格)
| 功能 | 分类 | 取舍 | 页面 | 接口 | 数据 | 甲方实现 | 体验 | 迁移动作 |
|------|------|------|------|------|------|----------|------|----------|
| 链接人与事 | 仅稳定版有 | 保留 | - | - | - | - | - | 不删 |
| 钱包 | 仅新版有 | 迁移 | wallet | balance/* | user_balance | ⚠️ 不完整 | 待补空态 | 迁界面+补 consume 写 orders |
### 5.2 接口规范与冲突清单(表格)
| 接口 | 甲方实现 | 规范要求 | 冲突说明 | 处理 |
|------|----------|----------|----------|------|
| POST balance/consume | 只扣余额 | 扣余额+写 orders | check-purchased 判未购买 | 重写 consume |
### 5.3 需求评审清单(迁移前必产出)
| 类型 | 项 | 说明 | 确认 |
|------|-----|------|------|
| 功能点 | 钱包页 | 新增余额、充值、交易记录 | ☐ |
| 功能点 | 我的页余额入口 | 第 4 项统计,点击进 wallet | ☐ |
| 样式变更 | 首页精选展开 | 默认 3 条,可展开更多 | ☐ |
| 样式变更 | Banner 按钮文案 | 「开始阅读」→「点击阅读」 | ☐ |
**确认**:每个项经评审确认后再迁;未确认不迁。
### 5.4 功能闭环 Checklist每功能必过
```
□ 界面:页面、交互、数据绑定
□ 接口API 存在、参数正确、响应格式规范
□ 数据DB/事务/幂等
□ 边界:未登录、余额不足、网络失败
□ 三端:小程序+后端+管理端(如需)是否都改到
□ 保护区域:未动 @/#、分销、支付 核心逻辑;若涉及则在原逻辑上扩展
```
---
## 6. 与其它 Skill 的衔接
| 阶段 | 衔接 Skill |
|------|------------|
| 分析完成,开始实现 | **change-checklist**:变更完成必过三端关联检查 |
| 实现完成,经验沉淀 | **assistant-doc-sync**:吸收经验、同步需求文档 |
| 新增需求需三端规划 | **product-manager**:加个需求 → 三端分析 → 指派 |
| 跨端功能开发 | **role-flow-control**:后端先行 → 小程序 → 管理端 |
| 需求评审 | **team-meeting**:开会、需求评审 → 各角色发言 → 形成决议 |
---
## 7. 执行顺序(单次分析)
1. **Read 本 Skill** 完整内容
2. **功能对齐与取舍**:先做三向分类(仅稳定版有/仅新版有/两者共有),产出取舍决策表
3. **快速摸底**:双向对比 new-soul 与稳定版,产出/更新功能差异清单(含分类、取舍列)
4. **逻辑分层**:每个功能过三层(界面/接口/数据)
5. **体验评估**:补充空态、错误态、边界处理
6. **接口规范与冲突**:产出接口规范与冲突清单
7. **抽取需求**:写入需求汇总,形成迁移任务清单
8. **需求评审(迁移前必做)**:列出功能点 + 样式变更,逐一确认,产出评审清单
9. **回复用户**:给出分析摘要 + 文档路径 + 建议执行顺序;**迁移须在需求评审通过后开始**
---
## 8. 文档写入位置
| 文档 | 路径 |
|------|------|
| 功能差异清单 | `开发文档/新版迁移-功能差异清单.md` |
| 接口规范与冲突 | `开发文档/新版迁移-接口规范与冲突清单.md` |
| 迁移方案/清单 | `开发文档/新版迁移-开发方案与清单.md``新版功能迁移到稳定版方案.md` |
| **需求评审清单** | `开发文档/新版迁移-需求评审清单.md`(功能点 + 样式变更,含确认状态) |
| 需求汇总 | `开发文档/1、需求/需求汇总.md` |
若已有同名文档,在其基础上**追加或更新**,不重复创建。
---
## 9. 一句话总结
**迁移前必做需求评审**:列出功能点 + 样式变更,逐一确认后再迁。先做功能对齐与取舍,评审通过后按最小功能迁移;界面修改先迁、大逻辑排后;@/#、分销、支付为保护区域;用五步分析产出差异清单、接口冲突表、评审清单,再按 checklist 逐功能闭环。

View File

@@ -1,6 +1,6 @@
---
name: soul-product-manager
description: Soul 创业派对产品经理需求与验收。需求分析、需求文档、验收标准、与开发对接。Use when 需求分析, 需求文档, 验收, 产品经理.
description: Soul 创业派对产品经理需求与验收。需求分析、需求文档、验收标准、与开发对接。Use when 需求分析, 需求文档, 验收, 产品经理, 加个需求.
---
# Soul 创业派对 - 产品经理 Skill
@@ -9,6 +9,65 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
---
## 0. 加个需求流水线(优先执行)
当用户说**「加个需求xxxxxxx」**(具体内容)时,产品经理**必须**执行以下流程,确保三端功能闭环。
### 0.1 触发与解析
- **触发词**`加个需求``加个需求xxx`(理解意图即可)
- **解析**:提取用户描述的具体功能或变更点
### 0.2 三端分析(功能闭环)
对每个需求,**必须**分析三端各自需要哪些调整:
| 端 | 分析要点 | 典型产出 |
|----|----------|----------|
| **小程序** | 新增/改版页面、交互、调用的 miniprogram 接口 | 页面路径、功能要点、接口依赖 |
| **管理端** | 是否**确有**管理能力需求? | 新增列表/表单、配置项、审核、统计、开关(无则写「无」) |
| **后端** | 接口、数据模型、路由分组 | miniprogram/admin/db 接口契约、表/字段变更 |
**判断原则**
- 新增功能 → 常伴随:管理端**配置项**(开关、文案、规则)、**列表/审核**(若涉及用户提交)、**统计**(若涉及数据展示)
- 若仅小程序展示 → 可能只需 miniprogram 接口,管理端无变更
- 若涉及业务规则/开关 → 管理端「系统设置」或独立配置页;后端 config 或专用表
**合理性约束(必守)**
- **按实际情况判断**,不因需求表述而过度设计。例如:单纯改文案、改按钮文字、改提示语 → **不需要**新增管理列表、文案管理、配置项;直接改前端代码即可。
- 管理端/后端调整**仅在确有管理或数据需求时**才规划:需要运营配置、需要审核、需要统计、需要多端复用同一文案等。
- 不确定时,优先给出**最小可行方案**,避免为小改动堆砌管理能力。
### 0.3 功能规划与协调变更
1. **输出需求分析**:写入 `临时需求池/YYYY-MM-DD-需求简述.md` 或追加到 `需求汇总.md` 需求清单
2. **三端任务拆分**:按上表列出「小程序任务」「管理端任务」「后端任务」
3. **协调变更**:若需更新《以界面定需求》,同步更新界面清单与业务逻辑
4. **指派**:明确各任务对应角色(小程序开发工程师、管理端开发工程师、后端工程师),并给出执行顺序建议(通常:后端 → 小程序;管理端视依赖可并行或后置)
### 0.4 产出模板
```
【需求】用户描述
【三端分析】
- 小程序xxx
- 管理端xxx若无则写「无」
- 后端xxx
【任务指派】
1. 后端xxx
2. 小程序xxx
3. 管理端xxx若需要
【文档更新】以界面定需求 / 需求汇总 / 临时需求池
```
### 0.5 与 role-flow-control 的配合
本流水线与 `SKILL-role-flow-control` 协同:产品经理完成三端分析与指派后,开发执行时按 role-flow-control 的协同流程(需求分析 → 并行开发 → 管理端启动 → 联调)推进。
---
## 1. 职责范围
| 职责 | 说明 | 产出 |
@@ -62,10 +121,11 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
## 6. 何时选用
- 用户说**「加个需求xxx」**时:执行 §0 加个需求流水线(三端分析 → 功能规划 → 指派)
- 编辑 `开发文档/1、需求/``临时需求池/``开发文档/10、项目管理/`
- 进行需求分析、需求文档编写、验收标准定义时
- 用户说「需求分析」「产品经理」「验收」时
---
**更新日期**2026-02
**更新日期**2026-03

View File

@@ -1,225 +1,88 @@
---
name: soul-tester
description: Soul 创业派对测试人员。全站深度测试、功能测试、回归测试、三端联调验证、安全审计。Use when 测试, 测试用例, 回归测试, 功能测试, QA, 全站测试, 深度测试.
version: "2.0"
updated: "2026-03-15"
description: Soul 创业派对测试人员。功能测试、回归测试、三端小程序、管理端、API联调验证。Use when 测试, 测试用例, 回归测试, 功能测试, QA.
---
# Soul 创业派对 - 测试 Skill v2.0
# Soul 创业派对 - 测试人员 Skill
> **定位**Soul 创业派对项目的专业测试规范。基于 2026-03-15 全站深度测试经验沉淀覆盖管理端、小程序、API、数据库四个维度
>
> **核心原则**:逐页逐按钮逐功能,三端隔离验证,数据交叉校验,安全必查。
当你在**功能测试、回归测试、三端联调验证**时,使用本 Skill。测试人员负责小程序、管理端、API 的功能与集成测试,不参与源码编写
---
## 1. 测试范围
## 1. 职责范围
| | 目录 | API 路径 | 数据表 |
|----|------|----------|--------|
| 管理端 | soul-admin/ | /api/admin/*, /api/db/* | 25 张表 |
| 小程序 | miniprogram/ | /api/miniprogram/* | 同上 |
| API 后端 | soul-api/ | 全部 | 同上 |
| 数据库 | MySQL | — | users, chapters, orders 等 25 张 |
| 职责 | 说明 | 产出 |
|------|------|------|
| 功能测试 | 按需求验证功能正确性 | 测试用例、通过/失败记录 |
| 回归测试 | 变更后验证原有功能未破坏 | 回归清单、测试报告 |
| 三端联调 | 小程序↔API、管理端↔API 数据流验证 | 联调记录 |
| Bug 反馈 | 复现步骤、环境、期望 vs 实际 | Bug 列表、复现说明 |
---
## 2. 测试执行检查清单(每次必做)
## 2. 测试范围
### 2.1 环境检查Step 0
- [ ] 后端 API 运行(`curl localhost:8080/health`
- [ ] 管理端前端运行(`curl localhost:5174`
- [ ] 数据库连接正常(通过 /api/book/stats 间接验证)
- [ ] OSS 配置状态(/api/admin/settings → ossConfig 有值)
### 2.2 管理端页面检查24 个页面/子Tab
**登录**
- [ ] 登录页 UI 完整(表单、按钮、品牌标识)
- [ ] 错误凭据有明确错误提示
- [ ] 正确凭据登录成功跳转
- [ ] **路由守卫**:未登录直接访问 /dashboard 应跳转 /login
**仪表盘**
- [ ] 4 个统计卡片数据正确(用户数、收入、订单数、转化率)
- [ ] 最近订单列表有数据
- [ ] 新注册用户列表**用户名不应显示为"-"**
- [ ] 分类标签点击统计有数据或合理空状态提示
**内容管理**5 个 Tab
- [ ] 章节管理:树结构完整,篇/章/节层级
- [ ] 内容排行榜:排名、浏览量、付费数、热度完整
- [ ] 内容搜索:搜索框可用
- [ ] 链接人与事AI 列表展示
- [ ] 链接标签:标签 CRUD
**用户管理**2 个 Tab
- [ ] 用户列表:昵称、手机号、付费状态、分销、分页
- [ ] 用户旅程8 阶段漏斗数据
**找伙伴**5 个 Tab
- [ ] 数据统计匹配次数、用户数、AI 获客
- [ ] 匹配记录:列表展示
- [ ] 匹配池设置:来源池、基础设置
- [ ] 导师管理:导师列表和价格
- [ ] 团队招募:招募记录
**推广中心**5 个 Tab
- [ ] 数据概览:今日/本月/累计统计
- [ ] 订单管理:订单列表、搜索、分页
- [ ] 绑定管理:绑定关系列表
- [ ] 提现审核:提现记录和审核功能
- [ ] 推广设置:收益率、提现规则
**系统设置**4 个 Tab
- [ ] 系统设置功能开关、价格、OSS、小程序配置
- [ ] 作者详情:基本信息、统计、亮点
- [ ] 管理员:管理员列表
- [ ] API 文档:接口文档完整
### 2.3 API 端点检查35+ 端点)
**公开 API无需 Token**
- [ ] GET /health → 200
- [ ] GET /api/config → 200配置完整
- [ ] GET /api/book/all-chapters → 200章节数与 DB 一致
- [ ] GET /api/book/hot → 200热度排行有数据
- [ ] GET /api/book/recommended → 200
- [ ] GET /api/book/search?q=创业 → 200有结果注意返回字段是 `results` 不是 `data`
- [ ] GET /api/book/search?q= → 200空结果
- [ ] GET /api/book/stats → 200交叉验证章节数
**小程序 API无需 Token**
- [ ] GET /api/miniprogram/config → 200
- [ ] GET /api/miniprogram/book/hot → 200
- [ ] GET /api/miniprogram/book/stats → 200
- [ ] GET /api/miniprogram/mentors → 200
- [ ] GET /api/miniprogram/vip/members → 200
**管理端 API需 Token**
- [ ] POST /api/admin → 登录获取 token
- [ ] GET /api/admin/dashboard/stats → 200
- [ ] GET /api/admin/chapters → 200
- [ ] GET /api/admin/users → 200
- [ ] GET /api/admin/orders → 200注意返回字段 `orders` 不是 `data`
- [ ] GET /api/admin/track/stats → 200
- [ ] GET /api/admin/settings → 200**检查 OSS 密钥是否脱敏**
- [ ] GET /api/admin/referral-settings → 200
- [ ] GET /api/admin/author-settings → 200
**DB API需 Token**
- [ ] GET /api/db/book?action=list → 200
- [ ] GET /api/db/ckb-leads → 200
- [ ] GET /api/db/ckb-plan-stats → 200
- [ ] GET /api/db/vip-roles → 200
- [ ] GET /api/db/mentors → 200
- [ ] GET /api/db/persons → 200
**安全测试**
- [ ] 无 Token → /api/admin/settings → 401
- [ ] 错误 Token → /api/admin/settings → 401
- [ ] POST /api/admin 空 body → 错误提示
- [ ] POST /api/upload 非图片 → 拒绝
### 2.4 数据库检查
- [ ] 25 张表结构完整AutoMigrate 无报错)
- [ ] 关键数据量核对chapters/users/orders 与 API 一致
- [ ] 无重复订单号
- [ ] stats vs all-chapters 章节数交叉验证
- [ ] orders vs dashboard 收入交叉验证
### 2.5 小程序代码审查25 个页面)
- [ ] 所有页面 API 路径遵循 `/api/miniprogram/*`(不混调 admin/db
- [ ] 无废弃 API 使用wx.getUserProfile / wx.createCanvasContext
- [ ] 模块导入方式统一(不混用 import/require 和 export default/module.exports
- [ ] 无未使用工具文件(检查 utils/ 下每个文件是否被引用)
- [ ] 无硬编码baseUrl/appId/mchId/微信号/日期)
- [ ] 无 mock/test/debug 代码残留
- [ ] 无过多 console.log 调试日志
- [ ] 核心流程完整登录→阅读→购买→VIP→分销→提现→匹配
| 端 | 目录 | API 路径 | 重点 |
|----|------|----------|------|
| 小程序 | miniprogram/ | /api/miniprogram/* | 登录、支付、推荐码、VIP、阅读、分享 |
| 管理端 | soul-admin/ | /api/admin/*、/api/db/* | 内容管理、用户、订单、提现、VIP 角色、推广设置 |
| API 后端 | soul-api/ | 全部 | 接口契约、鉴权、分润、支付回调 |
---
## 3. 安全必查项(每次必做)
## 3. 测试原则
| 检查项 | 方法 | 标准 |
|--------|------|------|
| OSS 密钥脱敏 | GET /api/admin/settings 查 ossConfig | accessKeySecret 不应返回明文 |
| 管理端登录守卫 | 直接访问 /dashboard | 应跳转 /login |
| Token 有效性 | 过期/错误 Token 访问管理 API | 返回 401 |
| 上传文件类型限制 | POST /api/upload 上传 .txt | 应拒绝 |
| 支付参数来源 | 审查小程序支付代码 | 参数必须从后端获取 |
| 小程序敏感信息 | 审查 app.js | appId/mchId 不应硬编码 |
- **路径隔离**:小程序只调 miniprogram管理端只调 admin/db不得混用。
- **鉴权**:需登录接口需带 token401 时正确跳转登录。
- **数据流**下单→支付→回调→分润推荐码绑定→访问记录VIP 资料保存→排行展示。
- **变更检查**:开发完成变更后,可参考 soul-change-checklist 做关联检查,避免遗漏。
---
## 4. 经验库(持续沉淀)
## 4. 常用测试场景
### 4.1 API 响应字段陷阱
| 场景 | 验证点 |
|------|--------|
| 小程序登录 | 微信登录、手机号、token 持久化 |
| 购买与支付 | 下单、微信支付、回调更新、购买状态 |
| 推荐与分润 | 扫码/分享带 ref、绑定、分润计算 |
| VIP 功能 | 开通、资料填写、头像上传、保存、排行展示 |
| 管理端 CRUD | 列表、搜索、分页、新增、编辑、删除 |
| 提现 | 申请、审核、状态流转、到账确认 |
不同端点返回字段名不一致,测试脚本解析前先确认结构:
---
| 端点 | 数据字段 | 总数字段 |
|------|----------|----------|
| /api/book/search | `results` | `total` |
| /api/admin/orders | `orders` | `total` |
| /api/admin/users | `records` | `total` |
| /api/admin/settings | 直接在根层 | — |
| /api/book/hot | `data` | — |
| /api/book/stats | `data` | — |
## 5. 测试用例存放位置
### 4.2 OSS 上传测试四步法
1. **配置验证**GET /api/admin/settings → ossConfig 有值
2. **上传测试**POST /api/upload -F file=@test.png → 返回 storage=oss
3. **URL 可访问**curl 返回的 URL → HTTP 200
4. **ACL 策略**:阿里云新账号默认禁止公共访问,需用签名 URL
### 4.3 小程序代码审查 grep 命令
```bash
# 废弃 API 检查
grep -rn "getUserProfile\|createCanvasContext" miniprogram/
# 模块语法混用
grep -rn "export default" miniprogram/utils/
# 硬编码检查
grep -rn "apiBase\|hardcode\|28533368\|2025-01-01" miniprogram/
# mock/test 残留
grep -rn "mock\|Mock\|测试模式\|test mode" miniprogram/pages/
# console.log 统计
grep -rc "console.log" miniprogram/pages/ | grep -v ":0$"
```
### 4.4 通用经验2026-03-15 沉淀)
| 经验 | 详情 |
| 目录 | 用途 |
|------|------|
| 密钥返回前端必须脱敏 | 后端 settings API 返回 ossConfig/apiKey 等时secret 类字段只返回 `****` |
| SPA 管理端必须有路由守卫 | 未登录用户访问任何管理页面必须跳转 /login |
| API 失败绝不伪装成功 | catch 中不可设置 success=true必须真实反馈 |
| 上线前清理 mock/test 代码 | `grep -r "mock\|test mode\|测试模式"` |
| 上线前清理 console.log | `grep -rc "console.log"` |
| 微信 API 每年检查废弃 | wx.getUserProfile(2022)、wx.createCanvasContext(即将) |
| 三端路径隔离是底线 | 小程序只调 miniprogram管理端只调 admin/db |
| 聚合统计必须交叉验证 | stats 的数字要与 list API 返回的实际条数对比 |
| 分页必须实际翻页验证 | 不只测第一页,要测 page=2 和超出范围的 page |
| 签名 URL 有有效期 | OSS 私有 bucket 用签名 URL注意设置足够长的过期时间 |
| `scripts/test/miniapp/` | 小程序接口测试(/api/miniprogram/* |
| `scripts/test/web/` | 管理端测试(/api/admin/*、/api/db/* |
| `scripts/test/process/` | 流程测试(跨端多接口串联) |
测试工程师在此编写与维护测试用例,按 miniapp / web / process 分类存放。
**环境配置**:必须明确指定 SOUL_TEST_ENVlocal/souldev/soulapi或 SOUL_API_BASE配置从 soul-api/.env* 或 .env.test 读取。运行前报告头部会显示「测试环境: xxx」确认无误后再执行避免误测正式库。
**归档规则**:管理端+后端混合 → process/;仅小程序 → miniapp/;仅管理端/后端 → web/。需求变更时更新用例,无变更则复用。
---
## 5. 产出与协同
## 6. 产出与协同
| 产出 | 路径 |
| 产出 | 说明 |
|------|------|
| 测试报告 | `开发文档/全站测试报告_YYYYMMDD.md` |
| 截图存档 | 浏览器测试自动截图 |
| 飞书通知 | 测试完成后发飞书开发群 |
| 经验沉淀 | 本文件 §4 + `.cursor/agent/软件测试/evolution/` |
| 测试用例 | 场景、步骤、期望结果,存放于 scripts/test/ |
| 测试报告 | 通过率、失败用例、环境信息 |
| Bug 列表 | 复现步骤、关联端、严重程度 |
**协同**:发现 Bug 时与对应角色对接(小程序/管理端/后端)验收前完成测试并输出报告。
**协同**:发现 Bug 时与对应开发角色(小程序/管理端/后端)对接;验收前完成测试并输出报告。
---
## 7. 何时使用本 Skill
- 编写或执行测试用例时
- 做回归测试、功能验证时
- 三端联调、接口契约验证时
- 说「测试」「测试用例」「回归测试」「功能测试」「QA」时

10
.gitignore vendored
View File

@@ -1,9 +1 @@
# 开发文档不上传 GitHub
开发文档/
# 常见忽略
node_modules/
.DS_Store
*.log
.env
.env.local
new-soul

0
ee.txt Normal file
View File

View File

@@ -1,37 +0,0 @@
@echo off
chcp 65001 >nul
title 一键启动 macOS 虚拟机 + TightVNC
echo ========================================
echo 一键启动 macOS 虚拟机(龙虾方案)
echo ========================================
echo.
set "VM_DIR=%USERPROFILE%\Mycontent\macos-vm\OneClick-macOS-Simple-KVM"
if not exist "%VM_DIR%\basic.sh" (
echo [错误] 未找到虚拟机目录: %VM_DIR%
echo 请先运行龙虾安装流程完成首次部署。
pause
exit /b 1
)
echo [1] 在新窗口启动 macOS 虚拟机8G 内存 / 4 核)...
start "macOS 虚拟机 - 勿关此窗口" wsl -d Ubuntu-24.04 -e bash -lc "cd /mnt/c/Users/%USERNAME%/Mycontent/macos-vm/OneClick-macOS-Simple-KVM && sudo HEADLESS=1 ./basic.sh"
echo [2] 等待约 10 秒后自动打开 TightVNC Viewer 连接 localhost:5900 ...
timeout /t 10 /nobreak >nul
set "TVN=%ProgramFiles%\TightVNC\tvnviewer.exe"
if not exist "%TVN%" set "TVN=%ProgramFiles(x86)%\TightVNC\tvnviewer.exe"
if exist "%TVN%" (
start "" "%TVN%" localhost::5900
echo [3] 已启动 TightVNC Viewer连接 localhost:5900
) else (
echo [3] 未找到 TightVNC请手动打开 VNC 客户端连接: localhost:5900
)
echo.
echo 虚拟机在「macOS 虚拟机 - 勿关此窗口」中运行,关闭该窗口会关闭虚拟机。
echo 本窗口可以关闭。
echo ========================================
pause

View File

@@ -35,8 +35,7 @@ miniprogram/
│ ├── purchases/ # 订单页
│ └── settings/ # 设置页
├── utils/
── util.js # 工具函数
│ └── payment.js # 支付工具
── util.js # 工具函数
├── assets/
│ └── icons/ # 图标资源
├── project.config.json # 项目配置

View File

@@ -4,44 +4,24 @@
*/
const { parseScene } = require('./utils/scene.js')
const DEFAULT_BASE_URL = 'https://soulapi.quwanzhi.com'
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
function getRuntimeBootstrapConfig() {
try {
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
return {
baseUrl: extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || DEFAULT_BASE_URL,
appId: extCfg.appId || DEFAULT_APP_ID,
mchId: extCfg.mchId || DEFAULT_MCH_ID,
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
}
} catch (_) {
return {
baseUrl: DEFAULT_BASE_URL,
appId: DEFAULT_APP_ID,
mchId: DEFAULT_MCH_ID,
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID
}
}
}
const bootstrapConfig = getRuntimeBootstrapConfig()
const { checkAndExecute } = require('./utils/ruleEngine.js')
App({
globalData: {
// 运行配置:优先外部配置/缓存,其次默认值
baseUrl: bootstrapConfig.baseUrl,
appId: bootstrapConfig.appId,
// API 基础地址(切换环境时注释/取消注释)
baseUrl: 'https://soulapi.quwanzhi.com',
// baseUrl: 'http://localhost:8080', // 本地调试
// baseUrl: 'https://souldev.quwanzhi.com', // 测试环境
// 小程序配置 - 真实AppID
appId: 'wxb8bbb2b10dec74aa',
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
withdrawSubscribeTmplId: bootstrapConfig.withdrawSubscribeTmplId,
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
// 微信支付配置
mchId: bootstrapConfig.mchId,
mchId: '1318592501', // 商户号
// 用户信息
userInfo: null,
@@ -50,8 +30,7 @@ App({
// 书籍数据
bookData: null,
totalSections: 0,
supportWechat: '',
totalSections: 62,
// 购买记录
purchasedSections: [],
@@ -92,6 +71,8 @@ App({
},
onLaunch(options) {
// 清理已废弃的调试环境 storage取消环境切换后不再使用
try { wx.removeStorageSync('debug_env_override') } catch (_) {}
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 获取系统信息
this.getSystemInfo()
@@ -108,10 +89,11 @@ App({
// 检查登录状态
this.checkLoginStatus()
this.loadRuntimeConfig()
// 加载书籍数据
this.loadBookData()
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId 等,失败时保留默认值)
this.loadMpConfig()
// 检查更新
this.checkUpdate()
@@ -230,31 +212,6 @@ App({
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
},
// 判断用户资料是否完善(昵称 + 头像)
_isProfileIncomplete(user) {
if (!user) return true
const nickname = (user.nickname || '').trim()
const avatar = (user.avatar || '').trim()
const isDefaultNickname = !nickname || nickname === '微信用户'
const noAvatar = !avatar
return isDefaultNickname || noAvatar
},
// 登录后若资料未完善,引导跳转到资料编辑页
_ensureProfileCompletedAfterLogin(user) {
try {
if (!user || !this._isProfileIncomplete(user)) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
// 避免在资料页内重复跳转
if (current && current.route === 'pages/profile-edit/profile-edit') return
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
} catch (e) {
console.warn('[App] 跳转资料编辑页失败:', e)
}
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []
@@ -351,23 +308,6 @@ App({
}
},
async loadRuntimeConfig() {
try {
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
const mpConfig = res?.mpConfig || {}
this.globalData.baseUrl = mpConfig.apiDomain || this.globalData.baseUrl
this.globalData.appId = mpConfig.appId || this.globalData.appId
this.globalData.mchId = mpConfig.mchId || this.globalData.mchId
this.globalData.withdrawSubscribeTmplId = mpConfig.withdrawSubscribeTmplId || this.globalData.withdrawSubscribeTmplId
this.globalData.supportWechat = mpConfig.supportWechat || mpConfig.customerWechat || mpConfig.serviceWechat || ''
try {
wx.setStorageSync('apiBaseUrl', this.globalData.baseUrl)
} catch (_) {}
} catch (e) {
console.warn('[App] 加载运行配置失败,继续使用默认配置:', e)
}
},
// 加载书籍数据
async loadBookData() {
try {
@@ -382,7 +322,7 @@ App({
if (res && (res.data || res.chapters)) {
const chapters = res.data || res.chapters || []
this.globalData.bookData = chapters
this.globalData.totalSections = res.total || chapters.length || 0
this.globalData.totalSections = (chapters && chapters.length) ? chapters.length : 62
wx.setStorageSync('bookData', chapters)
}
} catch (e) {
@@ -390,6 +330,21 @@ App({
}
},
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId 等),失败时保留 globalData 默认值
async loadMpConfig() {
try {
const res = await this.request({ url: '/api/miniprogram/config', silent: true })
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
}
},
/**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)
@@ -467,12 +422,10 @@ App({
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
@@ -562,8 +515,8 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
// 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎)
checkAndExecute('after_login', null)
}
return res.data
@@ -624,8 +577,8 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
}
return res.data.openId
}
@@ -636,6 +589,13 @@ App({
return null
},
// 模拟登录已废弃 - 不再使用
// 现在必须使用真实的微信登录获取openId作为唯一标识
mockLogin() {
console.warn('[App] mockLogin已废弃请使用真实登录')
return null
},
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
if (!this.ensureFullAppForAuth()) {
@@ -671,8 +631,8 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
return res.data
}

View File

@@ -6,7 +6,6 @@
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
"pages/about/about",
"pages/agreement/agreement",
"pages/privacy/privacy",
"pages/referral/referral",
@@ -16,13 +15,16 @@
"pages/addresses/addresses",
"pages/addresses/edit",
"pages/withdraw-records/withdraw-records",
"pages/wallet/wallet",
"pages/vip/vip",
"pages/member-detail/member-detail",
"pages/mentors/mentors",
"pages/mentor-detail/mentor-detail",
"pages/profile-show/profile-show",
"pages/profile-edit/profile-edit",
"pages/wallet/wallet"
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -31,7 +31,7 @@
<text class="doc-p">我们可能适时修订本协议,修订后将在小程序内公示。若您继续使用服务,即视为接受修订后的协议。</text>
<text class="doc-section">七、联系我们</text>
<text class="doc-p">如有疑问,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
<text class="doc-p">如有疑问,请通过 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

View File

@@ -0,0 +1,155 @@
/**
* Soul创业派对 - 头像昵称引导页
* 登录后资料未完善时引导用户修改默认头像和昵称,仅包含头像+昵称两项
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
avatar: '',
nickname: '',
saving: false,
showAvatarModal: false,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadFromUser()
},
loadFromUser() {
const user = app.globalData.userInfo
if (!app.globalData.isLoggedIn || !user?.id) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => getApp().goBackOrToHome(), 1500)
return
}
const nickname = (user.nickname || user.nickName || '').trim()
const avatar = user.avatar || user.avatarUrl || ''
this.setData({ nickname, avatar })
},
goBack() {
getApp().goBackOrToHome()
},
onNicknameInput(e) {
this.setData({ nickname: e.detail.value })
},
onNicknameChange(e) {
this.setData({ nickname: e.detail.value })
},
onAvatarTap() {
wx.showActionSheet({
itemList: ['使用微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) {
this.setData({ showAvatarModal: true })
} else if (res.tapIndex === 1) {
this.chooseAvatarFromAlbum()
}
},
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
},
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
await this.uploadAndSaveAvatar(tempPath)
},
})
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
await this.uploadAndSaveAvatar(tempAvatarUrl)
},
async uploadAndSaveAvatar(tempPath) {
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempPath,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch {
reject(new Error('解析失败'))
}
},
fail: reject,
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
this.setData({ avatar: avatarUrl })
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
}
},
async saveProfile() {
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const nickname = (this.data.nickname || '').trim()
const avatar = (this.data.avatar || '').trim()
if (!nickname) {
wx.showToast({ title: '请输入昵称', icon: 'none' })
return
}
this.setData({ saving: true })
try {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId, avatar: avatar || undefined, nickname },
})
if (app.globalData.userInfo) {
if (nickname) app.globalData.userInfo.nickname = nickname
if (avatar) app.globalData.userInfo.avatar = avatar
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => getApp().goBackOrToHome(), 800)
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}
this.setData({ saving: false })
},
goToFullProfile() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "完善资料",
"usingComponents": {}
}

View File

@@ -0,0 +1,67 @@
<!--Soul创业派对 - 头像昵称引导页,仅头像+昵称-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><text class="back-icon"></text></view>
<text class="nav-title">完善资料</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content">
<!-- 引导文案 -->
<view class="guide-card">
<text class="guide-icon">👋</text>
<text class="guide-title">完善头像和昵称</text>
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
</view>
<!-- 头像 -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="onAvatarTap">
<view class="avatar-inner">
<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>
<text class="avatar-change">点击更换头像</text>
</view>
<!-- 昵称 -->
<view class="form-section">
<text class="form-label">昵称</text>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击输入框自动填充昵称,或手动输入</text>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '完成'}}
</view>
<view class="link-row" bindtap="goToFullProfile">
<text class="link-text">完善更多资料</text>
<text class="link-arrow">→</text>
</view>
</view>
<!-- 头像弹窗:使用微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,271 @@
/* Soul创业派对 - 头像昵称引导页 */
.page {
background: #050B14;
min-height: 100vh;
color: #fff;
width: 100%;
box-sizing: border-box;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 24rpx;
background: rgba(5, 11, 20, 0.9);
backdrop-filter: blur(8rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
}
.nav-back {
width: 60rpx;
padding: 16rpx 0;
}
.back-icon {
font-size: 44rpx;
color: #5EEAD4;
}
.nav-title {
font-size: 36rpx;
font-weight: 600;
}
.nav-placeholder {
width: 60rpx;
}
.content {
padding: 32rpx 24rpx;
box-sizing: border-box;
}
.guide-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 48rpx 32rpx;
background: rgba(94, 234, 212, 0.08);
border: 1rpx solid rgba(94, 234, 212, 0.25);
border-radius: 24rpx;
margin-bottom: 48rpx;
}
.guide-icon {
font-size: 64rpx;
margin-bottom: 16rpx;
}
.guide-title {
font-size: 36rpx;
font-weight: 700;
color: #5EEAD4;
margin-bottom: 12rpx;
}
.guide-desc {
font-size: 26rpx;
color: rgba(148, 163, 184, 0.95);
line-height: 1.5;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
}
.avatar-wrap {
position: relative;
width: 192rpx;
height: 192rpx;
border-radius: 50%;
border: 4rpx solid #5EEAD4;
box-shadow: 0 0 30rpx rgba(94, 234, 212, 0.3);
}
.avatar-inner {
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
display: block;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 72rpx;
font-weight: bold;
color: #5EEAD4;
background: rgba(94, 234, 212, 0.2);
}
.avatar-camera {
position: absolute;
bottom: -8rpx;
right: -8rpx;
width: 56rpx;
height: 56rpx;
background: #5EEAD4;
color: #000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
border: 4rpx solid #050B14;
box-sizing: border-box;
}
.avatar-change {
font-size: 28rpx;
color: #5EEAD4;
font-weight: 500;
margin-top: 16rpx;
}
.form-section {
margin-bottom: 32rpx;
}
.form-label {
display: block;
font-size: 24rpx;
color: #94A3B8;
margin-bottom: 12rpx;
margin-left: 8rpx;
}
.form-input-wrap {
padding: 24rpx 32rpx;
background: #17212F;
border: 1rpx solid rgba(255, 255, 255, 0.08);
border-radius: 24rpx;
box-sizing: border-box;
}
.form-input-inner {
width: 100%;
font-size: 28rpx;
color: #fff;
background: transparent;
box-sizing: border-box;
display: block;
}
.input-tip {
margin-top: 8rpx;
font-size: 22rpx;
color: #94A3B8;
margin-left: 8rpx;
display: block;
}
.save-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 36rpx;
font-weight: bold;
border-radius: 24rpx;
margin-top: 24rpx;
margin-bottom: 32rpx;
}
.save-btn[disabled] {
opacity: 0.6;
}
.link-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 24rpx;
}
.link-text {
font-size: 28rpx;
color: #94A3B8;
}
.link-arrow {
font-size: 28rpx;
color: #5EEAD4;
}
/* 头像弹窗 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(16rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 48rpx;
box-sizing: border-box;
}
.modal-content {
width: 100%;
max-width: 640rpx;
background: #0b1220;
border-radius: 32rpx;
padding: 48rpx;
position: relative;
box-sizing: border-box;
}
.modal-close {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.7);
}
.avatar-modal-title {
display: block;
font-size: 36rpx;
font-weight: 700;
text-align: center;
margin-bottom: 12rpx;
}
.avatar-modal-desc {
display: block;
font-size: 26rpx;
color: #94A3B8;
text-align: center;
margin-bottom: 32rpx;
}
.btn-choose-avatar {
width: 100%;
height: 88rpx;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: #5EEAD4;
color: #050B14;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
.btn-choose-avatar::after {
border: none;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}

View File

@@ -20,18 +20,34 @@ Page({
isVip: false,
purchasedSections: [],
// 书籍数据:以后台内容管理为准,仅用接口 /api/miniprogram/book/all-chapters 返回的数据
// 懒加载:篇章列表(不含章节详情),展开时再请求 chapters-by-part
totalSections: 0,
bookData: [],
// 展开状态:默认不展开任何篇章,直接显示目录
// 展开状态
expandedPart: null,
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
// 固定模块 id -> mid序言/尾声/附录,供 goToRead 传 mid
fixedSectionsMap: {},
// 附录
appendixList: [],
appendixList: [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话' },
{ id: 'appendix-2', title: '附录2创业者自检清单' },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源' }
],
// 每日新增章节
dailyChapters: []
// 每日新增章节(懒加载后暂无,可后续用 latest-chapters 补充)
dailyChapters: [],
// book/parts 加载中
partsLoading: true,
// 功能配置(搜索开关)
searchEnabled: true
},
onLoad() {
@@ -42,74 +58,115 @@ Page({
})
this.updateUserStatus()
this.loadVipStatus()
this.loadChaptersOnce()
this.loadParts()
this.loadDailyChapters()
this.loadFeatureConfig()
},
// 固定模块(序言、尾声、附录)不参与中间篇章
_isFixedPart(pt) {
if (!pt) return false
const p = String(pt).toLowerCase().replace(/[_\s|]/g, '')
return p.includes('序言') || p.includes('尾声') || p.includes('附录')
},
// 一次请求拉取全量目录,以后台内容管理为准;同时更新 totalSections / bookData / dailyChapters
async loadChaptersOnce() {
async loadFeatureConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const rows = (res && res.data) || (res && res.chapters) || []
// 无数据时清空目录,避免展示旧数据
if (rows.length === 0) {
app.globalData.bookData = []
wx.setStorageSync('bookData', [])
this.setData({
bookData: [],
totalSections: 0,
dailyChapters: [],
expandedPart: null
})
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
return
}
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
const features = (res && res.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
} catch (e) {
this.setData({ searchEnabled: true })
}
},
const totalSections = res.total ?? rows.length
app.globalData.bookData = rows
app.globalData.totalSections = totalSections
wx.setStorageSync('bookData', rows)
// bookData过滤序言/尾声/附录,按 part 聚合,篇章顺序按 sort_order 与后台一致含「2026每日派对干货」等
const filtered = rows.filter(r => !this._isFixedPart(r.partTitle || r.part_title))
const partMap = new Map()
// 懒加载:仅拉取篇章列表 + totalSections + fixedSections
// 优先 book/parts404 或失败时降级为 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
}
let parts = []
let totalSections = 0
let fixedSections = []
if (res?.success && Array.isArray(res.parts) && res.parts.length > 0) {
parts = res.parts
totalSections = res.totalSections ?? 0
fixedSections = res.fixedSections || []
} 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 = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
filtered.forEach((r) => {
const pid = r.partId || r.part_id || 'part-1'
const fixedMap = {}
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
const appendixList = [
{ id: 'appendix-1', title: '附录1Soul派对房精选对话', mid: fixedMap['appendix-1'] },
{ id: 'appendix-2', title: '附录2创业者自检清单', mid: fixedMap['appendix-2'] },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源', mid: fixedMap['appendix-3'] }
]
const bookData = parts.map((p, idx) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [] // 展开时懒加载
}))
app.globalData.totalSections = totalSections
this.setData({
bookData,
totalSections,
fixedSectionsMap: fixedMap,
appendixList,
_loadedChapters: {},
partsLoading: false
})
} catch (e) {
console.log('[Chapters] 加载篇章失败:', e)
this.setData({ bookData: [], totalSections: 0, partsLoading: false })
}
},
// 展开时懒加载该篇章的章节(含 mid供阅读页 by-mid 请求)
async loadChaptersByPart(partId) {
if (this.data._loadedChapters[partId]) return
try {
const res = await app.request({
url: `/api/miniprogram/book/chapters-by-part?partId=${encodeURIComponent(partId)}`,
silent: true
})
const rows = (res && res.data) || []
const chMap = new Map()
rows.forEach(r => {
const cid = r.chapterId || r.chapter_id || 'chapter-1'
const sortOrder = r.sectionOrder ?? r.sort_order ?? 999999
if (!partMap.has(pid)) {
const partIdx = partMap.size
partMap.set(pid, {
id: pid,
number: numbers[partIdx] || String(partIdx + 1),
title: r.partTitle || r.part_title || '未分类',
subtitle: r.chapterTitle || r.chapter_title || '',
chapters: new Map(),
minSortOrder: sortOrder
})
}
const part = partMap.get(pid)
if (sortOrder < part.minSortOrder) part.minSortOrder = sortOrder
if (!part.chapters.has(cid)) {
part.chapters.set(cid, {
if (!chMap.has(cid)) {
chMap.set(cid, {
id: cid,
title: r.chapterTitle || r.chapter_title || '未分类',
sections: []
})
}
const ch = part.chapters.get(cid)
const isPremium =
r.editionPremium === true ||
r.edition_premium === true ||
r.edition_premium === 1 ||
r.edition_premium === '1'
const ch = chMap.get(cid)
const isPremium = r.editionPremium === true || r.edition_premium === true || r.edition_premium === 1 || r.edition_premium === '1'
ch.sections.push({
id: r.id,
mid: r.mid ?? r.MID ?? 0,
@@ -120,57 +177,54 @@ Page({
isPremium
})
})
const partList = Array.from(partMap.values())
partList.sort((a, b) => (a.minSortOrder ?? 999999) - (b.minSortOrder ?? 999999))
const bookData = partList.map((p, idx) => ({
id: p.id,
number: numbers[idx] || String(idx + 1),
title: p.title,
subtitle: p.subtitle,
chapters: Array.from(p.chapters.values())
}))
const baseSort = 62
const appendixList = rows
.filter(r => {
const partTitle = String(r.partTitle || r.part_title || '')
return partTitle.includes('附录')
})
.sort((a, b) => (a.sort_order ?? a.sectionOrder ?? 999999) - (b.sort_order ?? b.sectionOrder ?? 999999))
.map(c => ({
id: c.id,
title: c.section_title || c.sectionTitle || c.title || c.chapterTitle || '附录'
}))
const daily = rows
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
.slice(0, 20)
.map(c => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title: c.section_title || c.title || c.sectionTitle,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({
bookData,
totalSections,
appendixList,
dailyChapters: daily,
expandedPart: this.data.expandedPart
const chapters = Array.from(chMap.values())
const loaded = { ...this.data._loadedChapters, [partId]: chapters }
const bookData = this.data.bookData.map(p =>
p.id === partId ? { ...p, chapters } : p
)
const bookDataFlat = app.globalData.bookData || []
rows.forEach(r => {
const idx = bookDataFlat.findIndex(c => c.id === r.id)
if (idx >= 0) bookDataFlat[idx] = { ...bookDataFlat[idx], ...r }
else bookDataFlat.push(r)
})
app.globalData.bookData = bookDataFlat
wx.setStorageSync('bookData', bookDataFlat)
this.setData({ bookData, _loadedChapters: loaded })
} catch (e) {
console.log('[Chapters] 加载目录失败:', e)
this.setData({ bookData: [], totalSections: 0 })
console.log('[Chapters] 加载章节失败:', e)
}
},
onPullDownRefresh() {
this.loadChaptersOnce().then(() => wx.stopPullDownRefresh()).catch(() => wx.stopPullDownRefresh())
Promise.all([this.loadParts(), this.loadDailyChapters()])
.then(() => wx.stopPullDownRefresh())
.catch(() => wx.stopPullDownRefresh())
},
// 每日新增:用 latest-chapters 接口,展示最近更新章节
async loadDailyChapters() {
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 daily = list
.filter(exclude)
.slice(0, 10)
.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,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
})
this.setData({ dailyChapters: daily })
} catch (e) { console.log('[Chapters] 加载每日新增失败:', e) }
},
onShow() {
@@ -213,13 +267,15 @@ Page({
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
},
// 切换展开状态
togglePart(e) {
// 切换展开状态,展开时懒加载该篇章章节
async togglePart(e) {
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
const partId = e.currentTarget.dataset.id
const isExpanding = this.data.expandedPart !== partId
this.setData({
expandedPart: this.data.expandedPart === partId ? null : partId
expandedPart: isExpanding ? partId : null
})
if (isExpanding) await this.loadChaptersByPart(partId)
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
@@ -245,6 +301,7 @@ Page({
// 跳转到搜索页
goToSearch() {
if (!this.data.searchEnabled) return
trackClick('chapters', 'nav_click', '搜索')
wx.navigateTo({ url: '/pages/search/search' })
},

View File

@@ -5,7 +5,7 @@
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-left">
<view class="search-btn" bindtap="goToSearch">
<view class="search-btn" wx:if="{{searchEnabled}}" bindtap="goToSearch">
<text class="search-icon">🔍</text>
</view>
</view>
@@ -17,8 +17,14 @@
<!-- 导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 目录加载中 -->
<view class="parts-loading" wx:if="{{partsLoading}}">
<view class="parts-loading-spinner"></view>
<text class="parts-loading-text">加载目录中...</text>
</view>
<!-- 书籍信息卡 -->
<view class="book-info-card card-gradient">
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}">
<view class="book-icon">
<view class="book-icon-inner">📚</view>
</view>
@@ -33,9 +39,34 @@
</view>
<!-- 目录内容 -->
<view class="chapters-content">
<!-- 序言 -->
<view class="chapter-item" bindtap="goToRead" data-id="preface">
<view class="chapters-content" wx:if="{{!partsLoading}}">
<!-- 每日新增(最近更新章节快捷入口) -->
<view class="daily-section" wx:if="{{dailyChapters.length > 0}}">
<view class="daily-header">
<text class="daily-title">每日新增</text>
<text class="daily-badge">+{{dailyChapters.length}}</text>
</view>
<view class="daily-list">
<view
class="daily-item"
wx:for="{{dailyChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="daily-dot"></view>
<view class="daily-content">
<text class="daily-item-title">{{item.title}}</text>
<text class="daily-item-meta">{{item.dateStr}} · ¥{{item.price}}</text>
</view>
<text class="daily-arrow"></text>
</view>
</view>
</view>
<!-- 序言(优先传 mid阅读页用 by-mid 请求) -->
<view class="chapter-item" bindtap="goToRead" data-id="preface" data-mid="{{fixedSectionsMap.preface}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">序言为什么我每天早上6点在Soul开播?</text>
@@ -59,14 +90,15 @@
</view>
</view>
<view class="part-right">
<text class="part-count">{{item.chapters.length}}章</text>
<text class="part-count">{{item.chapters.length || item.chapterCount}}章</text>
<text class="part-arrow {{expandedPart === item.id ? 'arrow-down' : ''}}">→</text>
</view>
</view>
<!-- 章节列表 - 展开时显示 -->
<!-- 章节列表 - 展开时显示,懒加载 -->
<block wx:if="{{expandedPart === item.id}}">
<view class="chapters-list">
<view wx:if="{{item.chapters.length === 0}}" class="chapters-loading">加载中...</view>
<block wx:for="{{item.chapters}}" wx:key="id" wx:for-item="chapter">
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
@@ -93,8 +125,8 @@
</view>
</view>
<!-- 尾声 -->
<view class="chapter-item" bindtap="goToRead" data-id="epilogue">
<!-- 尾声(优先传 mid -->
<view class="chapter-item" bindtap="goToRead" data-id="epilogue" data-mid="{{fixedSectionsMap.epilogue}}">
<view class="item-left">
<view class="item-icon icon-brand">📖</view>
<text class="item-title">尾声|这本书的真实目的</text>
@@ -115,6 +147,7 @@
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<text class="appendix-text">{{item.title}}</text>
<text class="appendix-arrow">→</text>

View File

@@ -75,6 +75,34 @@
width: 100%;
}
/* ===== 目录加载中 ===== */
.parts-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
gap: 24rpx;
}
.parts-loading-spinner {
width: 64rpx;
height: 64rpx;
border: 6rpx solid rgba(255, 255, 255, 0.1);
border-top-color: #00CED1;
border-radius: 50%;
animation: parts-spin 0.8s linear infinite;
}
.parts-loading-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
}
@keyframes parts-spin {
to { transform: rotate(360deg); }
}
/* ===== 书籍信息卡 ===== */
.book-info-card {
display: flex;
@@ -146,6 +174,89 @@
box-sizing: border-box;
}
/* ===== 每日新增 ===== */
.daily-section {
margin-bottom: 32rpx;
padding: 24rpx;
background: #1c1c1e;
border-radius: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.05);
}
.daily-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.daily-title {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
}
.daily-badge {
font-size: 22rpx;
padding: 4rpx 12rpx;
background: #F6AD55;
color: #ffffff;
border-radius: 20rpx;
}
.daily-list {
display: flex;
flex-direction: column;
gap: 0;
}
.daily-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.daily-item:last-child {
border-bottom: none;
}
.daily-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: rgba(0, 206, 209, 0.6);
margin-right: 20rpx;
flex-shrink: 0;
}
.daily-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.daily-item-title {
font-size: 26rpx;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.daily-item-meta {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.daily-arrow {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
margin-left: 16rpx;
}
/* ===== 章节项 ===== */
.chapter-item {
display: flex;
@@ -339,6 +450,12 @@
margin-left: 16rpx;
}
.chapters-loading {
padding: 24rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
}
.chapter-group {
background: rgba(28, 28, 30, 0.5);
border-radius: 16rpx;

View File

@@ -0,0 +1,117 @@
/**
* Soul创业派对 - 代付详情页
* 好友打开后看到订单信息,点击「帮他付款」完成代付
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
requestSn: '',
detail: null,
loading: true,
paying: false,
isInitiator: false // 是否发起人发起人看到「分享给好友」UI好友看到「帮他付款」
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
const requestSn = (options.requestSn || '').trim()
if (!requestSn) {
wx.showToast({ title: '代付链接无效', icon: 'none' })
setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 1500)
return
}
this.setData({ requestSn })
this.loadDetail()
},
async loadDetail() {
const { requestSn } = this.data
if (!requestSn) return
this.setData({ loading: true })
try {
const res = await app.request(`/api/miniprogram/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}`)
if (res && res.success) {
const myId = app.globalData.userInfo?.id || ''
const isInitiator = !!myId && res.initiatorUserId === myId
this.setData({ detail: res, loading: false, isInitiator })
} else {
this.setData({ loading: false })
wx.showToast({ title: res?.error || '加载失败', icon: 'none' })
}
} catch (e) {
this.setData({ loading: false })
wx.showToast({ title: '加载失败', icon: 'none' })
}
},
async doPay() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.switchTab({ url: '/pages/my/my' }), 1500)
return
}
const openId = app.globalData.openId || ''
if (!openId) {
wx.showToast({ title: '请先完成微信授权', icon: 'none' })
return
}
const { requestSn, detail } = this.data
if (!requestSn || !detail) return
this.setData({ paying: true })
wx.showLoading({ title: '创建订单中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/gift-pay/pay',
method: 'POST',
data: {
requestSn,
openId,
userId: app.globalData.userInfo?.id || ''
}
})
wx.hideLoading()
if (!res || !res.success || !res.data?.payParams) {
throw new Error(res?.error || '创建订单失败')
}
const payParams = res.data.payParams
payParams._orderSn = res.data.orderSn
await new Promise((resolve, reject) => {
wx.requestPayment({
...payParams,
signType: payParams.signType || 'MD5',
success: resolve,
fail: reject
})
})
wx.showToast({ title: '代付成功', icon: 'success' })
this.setData({ paying: false })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
}, 1500)
} catch (e) {
this.setData({ paying: false })
if (e.errMsg && e.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消支付', icon: 'none' })
} else {
wx.showToast({ title: e.message || e.error || '支付失败', icon: 'none' })
}
}
},
goBack() {
app.goBackOrToHome()
},
onShareAppMessage() {
const { requestSn } = this.data
return {
title: '好友请你帮忙代付 - Soul创业派对',
path: requestSn ? `/pages/gift-pay/detail?requestSn=${requestSn}` : '/pages/gift-pay/detail'
}
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"usingComponents": {}
}

View File

@@ -0,0 +1,74 @@
<!-- Soul创业派对 - 代付详情页(美团式:发起人看到分享入口,好友看到帮他付款) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-back" bindtap="goBack">
<text class="back-arrow">←</text>
</view>
<view class="nav-info">
<text class="nav-title">{{isInitiator ? '找朋友代付' : '帮他付款'}}</text>
</view>
<view class="nav-right-placeholder"></view>
</view>
</view>
<view class="content" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
<block wx:if="{{loading}}">
<view class="loading-box">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</block>
<block wx:elif="{{detail}}">
<!-- 营销:章节标题+内容预览,吸引代付人 -->
<view class="article-preview" wx:if="{{detail.sectionTitle || detail.contentPreview}}">
<text class="article-title">{{detail.sectionTitle || detail.description || '代付商品'}}</text>
<text class="article-content" wx:if="{{detail.contentPreview}}">{{detail.contentPreview}}</text>
</view>
<view class="card">
<view class="card-header">
<view class="card-badge">代付订单</view>
<text class="initiator" wx:if="{{!isInitiator}}">{{detail.initiatorNickname || '好友'}} 请你帮忙付款</text>
<text class="initiator" wx:else>分享给好友,好友帮你付款</text>
</view>
<view class="card-divider"></view>
<view class="card-body">
<view class="row product-row" wx:if="{{!detail.contentPreview}}">
<text class="label">商品</text>
<text class="value product-desc">{{detail.sectionTitle || detail.description || '-'}}</text>
</view>
<view class="row amount-row">
<text class="label">金额</text>
<text class="amount">¥{{detail.amount ? detail.amount.toFixed(2) : '0.00'}}</text>
</view>
</view>
</view>
<!-- 发起人:分享给好友 -->
<block wx:if="{{isInitiator}}">
<view class="tips">
<text class="tips-icon">💡</text>
<text>分享给好友,好友打开后点击「帮他付款」即可为你代付</text>
</view>
<button class="pay-btn share-btn" open-type="share">
<image class="btn-icon-img" src="/assets/icons/share.svg" mode="aspectFit"/>
<text>分享给好友</text>
</button>
</block>
<!-- 好友:帮他付款 -->
<block wx:else>
<view class="tips">
<text class="tips-icon">✓</text>
<text>付款后,{{detail.initiatorNickname || '好友'}}将获得对应权益</text>
</view>
<button class="pay-btn" bindtap="doPay" disabled="{{paying}}">
{{paying ? '支付中...' : '帮他付款'}}
</button>
</block>
</block>
<block wx:else>
<view class="empty">
<text>代付请求不存在或已处理</text>
</view>
</block>
</view>
</view>

View File

@@ -0,0 +1,264 @@
/* Soul创业派对 - 代付详情页 */
.page {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0a 0%, #000 40%, #000 100%);
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
height: 88rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
}
.nav-back:active {
opacity: 0.7;
}
.back-arrow {
font-size: 36rpx;
color: rgba(255, 255, 255, 0.9);
}
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #fff;
letter-spacing: 0.5rpx;
}
.content {
padding: 20rpx;
}
.loading-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid rgba(0, 206, 209, 0.2);
border-top-color: #00CED1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 24rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.45);
}
/* 营销:章节标题+内容预览,与订单卡片统一风格 */
.article-preview {
background: linear-gradient(145deg, #1a1a1c 0%, #141416 100%);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 16rpx;
border: 1rpx solid rgba(0, 206, 209, 0.1);
}
.article-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #fff;
line-height: 1.5;
margin-bottom: 12rpx;
}
.article-content {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
line-height: 1.65;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
/* 订单卡片:与文章预览统一圆角、边距 */
.card {
background: linear-gradient(145deg, #1a1a1c 0%, #141416 100%);
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 24rpx;
border: 1rpx solid rgba(0, 206, 209, 0.1);
}
.card-header {
padding: 24rpx;
}
.card-badge {
display: inline-block;
font-size: 22rpx;
color: rgba(0, 206, 209, 0.9);
background: rgba(0, 206, 209, 0.08);
padding: 6rpx 14rpx;
border-radius: 8rpx;
margin-bottom: 12rpx;
letter-spacing: 0.5rpx;
}
.initiator {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #fff;
line-height: 1.4;
letter-spacing: 0.3rpx;
}
.card-divider {
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent);
margin: 0 24rpx;
}
.card-body {
padding: 20rpx 24rpx 24rpx;
}
.row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16rpx;
}
.row:last-child {
margin-bottom: 0;
}
.label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.45);
flex-shrink: 0;
width: 80rpx;
}
.product-row .value {
flex: 1;
text-align: right;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.95);
line-height: 1.5;
word-break: break-all;
}
.product-desc {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.amount-row {
align-items: center;
}
.amount-row .amount {
font-size: 44rpx;
font-weight: 700;
color: #00CED1;
letter-spacing: 1rpx;
text-shadow: 0 0 24rpx rgba(0, 206, 209, 0.3);
}
/* 提示文案 */
.tips {
display: flex;
align-items: flex-start;
gap: 10rpx;
padding: 0 4rpx 24rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.tips-icon {
flex-shrink: 0;
font-size: 28rpx;
opacity: 0.8;
}
/* 主按钮 */
.pay-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: linear-gradient(135deg, #00CED1 0%, #18a8a8 50%, #20B2AA 100%);
color: #fff;
font-size: 34rpx;
font-weight: 600;
border-radius: 50rpx;
border: none;
box-shadow: 0 8rpx 24rpx rgba(0, 206, 209, 0.35);
transition: opacity 0.2s, transform 0.1s;
}
.pay-btn:active {
opacity: 0.92;
transform: scale(0.99);
}
.pay-btn[disabled] {
opacity: 0.6;
transform: none;
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.btn-icon-img {
width: 40rpx;
height: 40rpx;
filter: brightness(0) invert(1);
}
.empty {
text-align: center;
padding: 120rpx 0;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.45);
}

View File

@@ -0,0 +1,97 @@
/**
* Soul创业派对 - 我的代付
* Tab: 我发起的 / 我帮付的
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
tab: 'requests',
requests: [],
payments: [],
loading: false
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadData()
},
onShow() {
if (this.data.requests.length > 0 || this.data.payments.length > 0) {
this.loadData()
}
},
switchTab(e) {
const tab = e.currentTarget.dataset.tab || 'requests'
this.setData({ tab })
this.loadData()
},
async loadData() {
const userId = app.globalData.userInfo?.id || ''
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
this.setData({ loading: true })
try {
if (this.data.tab === 'requests') {
const res = await app.request(`/api/miniprogram/gift-pay/my-requests?userId=${encodeURIComponent(userId)}`)
this.setData({ requests: (res && res.list) || [], loading: false })
} else {
const res = await app.request(`/api/miniprogram/gift-pay/my-payments?userId=${encodeURIComponent(userId)}`)
this.setData({ payments: (res && res.list) || [], loading: false })
}
} catch (e) {
this.setData({ loading: false })
}
},
goToDetail(e) {
const requestSn = e.currentTarget.dataset.sn
if (requestSn) {
wx.navigateTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(requestSn)}` })
}
},
shareRequest(e) {
e.stopPropagation()
wx.showToast({ title: '请点击右上角「...」分享给好友', icon: 'none', duration: 2500 })
},
async cancelRequest(e) {
e.stopPropagation()
const requestSn = e.currentTarget.dataset.sn
if (!requestSn) return
const ok = await new Promise(r => {
wx.showModal({ title: '取消代付', content: '确定取消该代付请求?', success: res => r(res.confirm) })
})
if (!ok) return
try {
const res = await app.request({
url: '/api/miniprogram/gift-pay/cancel',
method: 'POST',
data: { requestSn, userId: app.globalData.userInfo?.id }
})
if (res && res.success) {
wx.showToast({ title: '已取消', icon: 'success' })
this.loadData()
} else {
wx.showToast({ title: res?.error || '取消失败', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '取消失败', icon: 'none' })
}
},
goBack() {
app.goBackOrToHome()
},
onShareAppMessage() {
return { title: '我的代付 - Soul创业派对', path: '/pages/gift-pay/list' }
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"usingComponents": {}
}

View File

@@ -0,0 +1,64 @@
<!-- Soul创业派对 - 我的代付 -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-back" bindtap="goBack">
<text class="back-arrow">←</text>
</view>
<view class="nav-info">
<text class="nav-title">我的代付</text>
</view>
<view class="nav-right-placeholder"></view>
</view>
</view>
<view class="tabs" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
<view class="tab {{tab === 'requests' ? 'active' : ''}}" data-tab="requests" bindtap="switchTab">我发起的</view>
<view class="tab {{tab === 'payments' ? 'active' : ''}}" data-tab="payments" bindtap="switchTab">我帮付的</view>
</view>
<view class="content">
<block wx:if="{{loading}}">
<view class="loading-box">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</block>
<block wx:elif="{{tab === 'requests'}}">
<block wx:if="{{requests.length === 0}}">
<view class="empty">暂无发起的代付</view>
</block>
<block wx:else>
<view class="card" wx:for="{{requests}}" wx:key="requestSn" bindtap="goToDetail" data-sn="{{item.requestSn}}">
<view class="card-row">
<text class="desc">{{item.description}}</text>
<text class="amount">¥{{item.amount}}</text>
</view>
<view class="card-row">
<text class="status {{item.status}}">{{item.status === 'pending' ? '待支付' : item.status === 'paid' ? '已支付' : item.status === 'cancelled' ? '已取消' : '已过期'}}</text>
<view class="actions" wx:if="{{item.status === 'pending'}}">
<text class="action-text" bindtap="shareRequest" data-sn="{{item.requestSn}}">分享</text>
<text class="action-text cancel" bindtap="cancelRequest" data-sn="{{item.requestSn}}">取消</text>
</view>
</view>
</view>
</block>
</block>
<block wx:else>
<block wx:if="{{payments.length === 0}}">
<view class="empty">暂无帮付记录</view>
</block>
<block wx:else>
<view class="card" wx:for="{{payments}}" wx:key="requestSn" bindtap="goToDetail" data-sn="{{item.requestSn}}">
<view class="card-row">
<text class="desc">{{item.description}}</text>
<text class="amount">¥{{item.amount}}</text>
</view>
<view class="card-row">
<text class="status {{item.status}}">{{item.status === 'paid' ? '已支付' : item.status}}</text>
</view>
</view>
</block>
</block>
</view>
</view>

View File

@@ -0,0 +1,160 @@
/* Soul创业派对 - 我的代付 */
.page {
min-height: 100vh;
background: #000;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.9);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
height: 88rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #1c1c1e;
display: flex;
align-items: center;
justify-content: center;
}
.back-arrow {
font-size: 36rpx;
color: rgba(255, 255, 255, 0.8);
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.tabs {
display: flex;
padding: 24rpx 32rpx;
gap: 24rpx;
background: #000;
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
border-radius: 12rpx;
background: #1c1c1e;
}
.tab.active {
color: #00CED1;
background: rgba(0, 206, 209, 0.15);
}
.content {
padding: 0 32rpx 32rpx;
}
.loading-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid rgba(0, 206, 209, 0.3);
border-top-color: #00CED1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 24rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
}
.empty {
text-align: center;
padding: 80rpx 0;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
}
.card {
background: #1c1c1e;
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.card-row:last-child {
margin-bottom: 0;
}
.desc {
font-size: 28rpx;
color: #fff;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount {
font-size: 32rpx;
font-weight: 600;
color: #00CED1;
margin-left: 16rpx;
}
.status {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
.status.paid {
color: #00CED1;
}
.actions {
display: flex;
gap: 24rpx;
}
.action-text {
font-size: 26rpx;
color: #00CED1;
}
.action-text.cancel {
color: rgba(255, 255, 255, 0.5);
}

View File

@@ -4,9 +4,9 @@
* 技术支持: 存客宝
*/
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
Page({
data: {
@@ -20,13 +20,15 @@ Page({
readCount: 0,
// 书籍数据
totalSections: 0,
totalSections: 62,
bookData: [],
// 精选推荐按热度排行默认显示3篇可展开更多
featuredSections: [],
featuredSectionsAll: [],
featuredExpanded: false,
// 推荐章节
featuredSections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 最新章节(动态计算)
latestSection: null,
@@ -45,10 +47,9 @@ Page({
superMembers: [],
superMembersLoading: true,
// 最新新增章节
// 最新新增章节(完整列表 + 展示列表,用于展开/折叠)
latestChapters: [],
latestChaptersExpanded: false,
latestChaptersAll: [],
displayLatestChapters: [],
// 篇章数(从 bookData 计算)
partCount: 0,
@@ -58,10 +59,21 @@ Page({
// 链接卡若 - 留资弹窗
showLeadModal: false,
leadPhone: ''
leadPhone: '',
// 展开状态(首页精选/最新)
featuredExpanded: false,
latestExpanded: false,
featuredSectionsFull: [], // 展开时用 book/hot 加载的完整列表
featuredExpandedLoading: false,
// 功能配置(搜索开关)
searchEnabled: true
},
onLoad(options) {
console.log('[Index] ===== onLoad 触发 =====')
// 获取系统信息
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
@@ -70,19 +82,26 @@ Page({
// 处理分享参数(推荐码绑定)
if (options && options.ref) {
console.log('[Index] 检测到推荐码:', options.ref)
app.handleReferralCode({ query: options })
}
wx.showShareMenu({ withShareTimeline: true })
this.loadFeatureConfig()
this.initData()
},
onShow() {
console.log('[Index] onShow 触发')
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
console.log('[Index] TabBar 组件:', tabBar ? '已找到' : '未找到')
// 主动触发配置加载
if (tabBar && tabBar.loadFeatureConfig) {
console.log('[Index] 主动调用 TabBar.loadFeatureConfig()')
tabBar.loadFeatureConfig()
}
@@ -92,24 +111,21 @@ Page({
} else if (tabBar) {
tabBar.setData({ selected: 0 })
}
} else {
console.log('[Index] TabBar 组件未找到或 getTabBar 方法不存在')
}
// 更新用户状态
this.updateUserStatus()
// 规则引擎:首页展示时检查(填头像、分享引导等)
checkAndExecute('page_show', this)
},
// 初始化数据:首次进页面并行异步加载,加快首屏展示
initData() {
Promise.all([
this.loadBookData(),
this.loadFeaturedFromServer(),
this.loadSuperMembers(),
this.loadLatestChapters()
]).finally(() => {
this.setData({ loading: false })
})
this.setData({ loading: false })
this.loadBookData()
this.loadFeaturedFromServer()
this.loadSuperMembers()
this.loadLatestChapters()
},
async loadSuperMembers() {
@@ -127,8 +143,13 @@ Page({
avatar: u.avatar || '',
isVip: true
}))
if (members.length > 0) {
console.log('[Index] 超级个体加载成功:', members.length, '人')
}
}
} catch (e) {}
} catch (e) {
console.log('[Index] vip/members 请求失败:', e)
}
// 不足 4 个则用有头像的普通用户补充
if (members.length < 4) {
try {
@@ -145,36 +166,73 @@ Page({
}
this.setData({ superMembers: members, superMembersLoading: false })
} catch (e) {
console.log('[Index] 加载超级个体失败:', e)
this.setData({ superMembersLoading: false })
}
},
// 从服务端获取精选推荐(按热度排行)和最新更新
// 从服务端获取精选推荐、最新更新stitch_soulbook/recommended、book/latest-chapters
async loadFeaturedFromServer() {
try {
// 1. 精选推荐: book/hot 获取热度排行数据
// 1. 精选推荐:优先用 book/recommended按阅读量+算法,带 热门/推荐/精选 标签)
let featured = []
try {
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
if (hotRes && hotRes.success && Array.isArray(hotRes.data) && hotRes.data.length > 0) {
const tagClassMap = { '热门': 'tag-hot', '推荐': 'tag-rec', '精选': 'tag-rec' }
const all = hotRes.data.map((s, i) => ({
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 || '',
tagClass: tagClassMap[s.tag] || 'tag-rec',
hotScore: s.hotScore || s.hot_score || 0,
hotRank: s.hotRank || (i + 1),
price: s.price ?? 1,
tag: s.tag || ['热门', '推荐', '精选'][i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
}))
this.setData({
featuredSectionsAll: all,
featuredSections: all.slice(0, 3),
featuredExpanded: false,
})
this.setData({ featuredSections: featured })
}
} catch (e) {}
} catch (e) { console.log('[Index] book/recommended 失败:', e) }
// 兜底:无 recommended 时从 book/hot 取前3
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 })
}
} 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 })
}
}
// 2. 最新更新:用 book/latest-chapters 取第1条排除「序言」「尾声」「附录」
try {
@@ -196,6 +254,7 @@ Page({
})
}
} 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 => {
@@ -215,7 +274,9 @@ Page({
})
}
}
} catch (e) {}
} catch (e) {
console.log('[Index] 从服务端加载推荐失败:', e)
}
},
async loadBookData() {
@@ -226,7 +287,7 @@ Page({
const partIds = new Set(chapters.map(c => c.partId || c.part_id || '').filter(Boolean))
this.setData({
bookData: chapters,
totalSections: res.total || chapters.length || app.globalData.totalSections || 0,
totalSections: res.total || chapters.length || 62,
partCount: partIds.size || 5
})
}
@@ -238,7 +299,7 @@ Page({
// 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的)
updateUserStatus() {
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
const readCount = Math.min(app.getReadCount(), this.data.totalSections || app.globalData.totalSections || 0)
const readCount = Math.min(app.getReadCount(), this.data.totalSections || 62)
this.setData({
isLoggedIn,
hasFullBook,
@@ -248,20 +309,35 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('home', 'nav_click', '目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
return
}
const res = await app.request({ url: '/api/miniprogram/config', silent: true })
const features = (res && res.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
} catch (e) {
this.setData({ searchEnabled: true })
}
},
// 跳转到搜索页
goToSearch() {
trackClick('home', 'nav_click', '搜索')
if (!this.data.searchEnabled) return
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id
trackClick('home', 'card_click', e.currentTarget.dataset.id || '章节')
const mid = e.currentTarget.dataset.mid || app.getSectionMid(id)
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
@@ -273,16 +349,10 @@ Page({
},
goToVip() {
trackClick('home', 'btn_click', 'VIP')
wx.navigateTo({ url: '/pages/vip/vip' })
},
goToAbout() {
wx.navigateTo({ url: '/pages/about/about' })
},
async onLinkKaruo() {
trackClick('home', 'btn_click', '链接卡若')
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
@@ -297,31 +367,23 @@ Page({
return
}
const userId = app.globalData.userInfo.id
// 2 分钟内只能点一次(与后端限频一致)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
}
} catch (e) {}
}
if ((!phone && !wechatId) || !avatar) {
wx.showModal({
title: '完善资料',
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
if (phone || wechatId) {
wx.showLoading({ title: '提交中...', mask: true })
try {
@@ -337,12 +399,8 @@ Page({
})
wx.hideLoading()
if (res && res.success) {
wx.showModal({
title: '提交成功',
content: '卡若会主动添加你微信,请注意你的微信消息',
showCancel: false,
confirmText: '好的'
})
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
@@ -405,6 +463,11 @@ Page({
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
wx.showLoading({ title: '提交中...', mask: true })
@@ -421,6 +484,7 @@ Page({
wx.hideLoading()
this.setData({ showLeadModal: false, leadPhone: '' })
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
// 同步手机号到用户资料
try {
if (userId) {
@@ -449,7 +513,6 @@ Page({
},
async submitLead() {
trackClick('home', 'btn_click', '提交留资')
const phone = (this.data.leadPhone || '').trim().replace(/\s/g, '')
if (!phone) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
@@ -462,76 +525,75 @@ Page({
wx.switchTab({ url: '/pages/match/match' })
},
// 精选推荐:展开/折叠
async toggleFeaturedExpanded() {
if (this.data.featuredExpandedLoading) return
if (this.data.featuredExpanded) {
const collapsed = this.data.featuredSectionsFull.length > 0 ? this.data.featuredSectionsFull.slice(0, 3) : this.data.featuredSections
this.setData({ featuredExpanded: false, featuredSections: collapsed })
return
}
if (this.data.featuredSectionsFull.length > 0) {
this.setData({ featuredExpanded: true, featuredSections: this.data.featuredSectionsFull })
return
}
this.setData({ featuredExpandedLoading: true })
try {
const res = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
const list = (res && res.data) ? res.data : []
const tagMap = ['热门', '推荐', '精选']
const full = list.map((s, i) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
part: (s.partTitle || s.part_title || '').replace(/[_|]/g, ' ').trim(),
tag: tagMap[i % 3] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i % 3] || 'tag-rec'
}))
this.setData({
featuredSectionsFull: full,
featuredSections: full,
featuredExpanded: true,
featuredExpandedLoading: false
})
} catch (e) {
console.log('[Index] 加载精选更多失败:', e)
this.setData({ featuredExpandedLoading: false })
}
},
// 最新新增:展开/折叠(默认 5 条,点击展开剩余)
toggleLatestExpanded() {
const expanded = !this.data.latestExpanded
const display = expanded ? this.data.latestChapters : this.data.latestChapters.slice(0, 5)
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
},
// 最新新增:用 latest-chapters 接口(后端按 updated_at 取前 N 条),不拉全量,支持万级文章
async loadLatestChapters() {
try {
let chapters = app.globalData.bookData || []
if (!Array.isArray(chapters) || chapters.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
chapters = (res && res.data) || (res && res.chapters) || []
}
const 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('附录')
let candidates = chapters.filter(c => (c.isNew || c.is_new) === true && exclude(c))
if (candidates.length === 0) {
candidates = chapters.filter(exclude)
}
const sessionNum = (c) => {
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
const m = title.match(/第\s*(\d+)\s*场/) || title.match(/第(\d+)场/)
if (m) return parseInt(m[1], 10)
const id = c.id != null ? String(c.id) : ''
if (/^\d+$/.test(id)) return parseInt(id, 10)
return 0
}
const mapChapter = (c) => {
const d = new Date(c.updatedAt || c.updated_at || Date.now())
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
const rawContent = (c.content || '').replace(/<[^>]+>/g, '').trim()
let desc = ''
if (rawContent && rawContent.length > 0) {
const clean = rawContent.replace(/^#[\d.]+\s*/, '').trim()
desc = clean.length > 36 ? clean.slice(0, 36) + '...' : clean
}
return {
id: c.id,
mid: c.mid ?? c.MID ?? 0,
title,
desc,
price: c.price ?? 1,
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
}
}
const sorted = candidates.sort((a, b) => {
const na = sessionNum(a)
const nb = sessionNum(b)
if (na !== nb) return nb - na
return new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0)
})
const latestAll = sorted.slice(0, 10).map(mapChapter)
this.setData({
latestChaptersAll: latestAll,
latestChapters: latestAll.slice(0, 5),
latestChaptersExpanded: false,
})
} catch (e) {}
},
toggleLatestExpand() {
const all = this.data.latestChaptersAll || []
if (this.data.latestChaptersExpanded) {
this.setData({ latestChapters: all.slice(0, 5), latestChaptersExpanded: false })
} else {
this.setData({ latestChapters: all, latestChaptersExpanded: true })
}
},
toggleFeaturedExpand() {
const all = this.data.featuredSectionsAll || []
if (this.data.featuredExpanded) {
this.setData({ featuredSections: all.slice(0, 3), featuredExpanded: false })
} else {
this.setData({ featuredSections: all, featuredExpanded: true })
}
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) {

View File

@@ -24,8 +24,8 @@
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar" bindtap="goToSearch">
<!-- 搜索栏(根据配置显示) -->
<view class="search-bar" wx:if="{{searchEnabled}}" bindtap="goToSearch">
<view class="search-icon-wrap">
<text class="search-icon-text">🔍</text>
</view>
@@ -38,18 +38,31 @@
<!-- Banner卡片 - 最新章节(异步加载) -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-tag">最新更新</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">点击阅读</text>
<text class="banner-action-text">开始阅读</text>
<view class="banner-arrow">→</view>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-tag">最新更新</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><view class="banner-arrow">→</view></view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><view class="banner-arrow">→</view></view>
</view>
<!-- 阅读进度(设计稿:最新更新→阅读进度→超级个体) -->
<view class="progress-card" wx:if="{{isLoggedIn}}" bindtap="goToChapters">
<view class="progress-header">
<text class="progress-title">阅读进度</text>
<text class="progress-count">已读 {{readCount}}/{{totalSections}}</text>
</view>
<view class="progress-bar-wrapper">
<view class="progress-bar-bg">
<view class="progress-bar-fill" style="width: {{readCount && totalSections ? (readCount / totalSections * 100) : 0}}%;"></view>
</view>
</view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」) -->
@@ -91,10 +104,14 @@
</view>
</view>
<!-- 精选推荐(按热度排行默认3篇展开更多) -->
<view class="section" wx:if="{{featuredSections.length > 0}}">
<!-- 精选推荐(带 tag支持展开更多) -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<view class="section-more" wx:if="{{featuredSections.length > 0}}" bindtap="toggleFeaturedExpanded">
<text class="more-text">{{featuredExpandedLoading ? '加载中...' : (featuredExpanded ? '收起' : '展开更多')}}</text>
<text class="more-arrow">{{featuredExpanded ? '▲' : '▼'}}</text>
</view>
</view>
<view class="featured-list">
<view
@@ -107,47 +124,50 @@
>
<view class="featured-content">
<view class="featured-meta">
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag || '精选'}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
<view class="featured-arrow"></view>
</view>
</view>
<view class="expand-btn" bindtap="toggleFeaturedExpand" wx:if="{{(featuredSectionsAll.length || 0) > 3}}">
<text class="expand-text">{{featuredExpanded ? '收起' : '展开更多'}}</text>
<text class="expand-icon">{{featuredExpanded ? '∧' : ''}}</text>
</view>
</view>
<!-- 最新新增(时间线样式+ 展开/收起 -->
<!-- 最新新增(时间线样式,支持展开更多) -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<view class="daily-badge-wrap">
<text class="daily-badge">+{{latestChaptersAll.length || latestChapters.length}}</text>
<view class="section-header-right">
<view class="daily-badge-wrap">
<text class="daily-badge">+{{latestChapters.length}}</text>
</view>
<view class="section-more" wx:if="{{latestChapters.length > 5}}" bindtap="toggleLatestExpanded">
<text class="more-text">{{latestExpanded ? '收起' : '展开更多'}}</text>
<text class="more-arrow">{{latestExpanded ? '▲' : '▼'}}</text>
</view>
</view>
</view>
<view class="timeline-wrap">
<view class="timeline-line"></view>
<view class="timeline-list">
<view class="timeline-item {{index === 0 ? 'timeline-item-first' : ''}}" wx:for="{{latestChapters}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<view class="timeline-item {{index === 0 ? 'timeline-item-first' : ''}}" wx:for="{{displayLatestChapters}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<view class="timeline-dot"></view>
<view class="timeline-content">
<view class="timeline-row">
<text class="timeline-title">{{item.title}}</text>
<view class="timeline-left">
<text class="latest-new-tag">NEW</text>
<text class="timeline-title">{{item.title}}</text>
</view>
<view class="timeline-right">
<text class="timeline-price">¥{{item.price}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 展开/收起按钮 -->
<view class="expand-btn" bindtap="toggleLatestExpand" wx:if="{{(latestChaptersAll.length || 0) > 5}}">
<text class="expand-text">{{latestChaptersExpanded ? '收起' : '展开更多'}}</text>
<text class="expand-icon">{{latestChaptersExpanded ? '∧' : ''}}</text>
</view>
</view>
</view>
<!-- 底部留白 -->

View File

@@ -689,6 +689,12 @@
margin-bottom: 32rpx;
}
.section-header-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.daily-badge-wrap {
display: inline-flex;
align-items: center;

View File

@@ -5,8 +5,7 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const { checkAndExecute } = require('../../utils/ruleEngine.js')
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
@@ -105,7 +104,9 @@ Page({
// 加载匹配配置
async loadMatchConfig() {
try {
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET' })
const res = await app.request({ url: '/api/miniprogram/match/config', silent: true, method: 'GET',
method: 'GET'
})
if (res.success && res.data) {
// 更新全局配置,导师顾问类型强制显示「导师顾问」
@@ -196,7 +197,6 @@ Page({
// 选择匹配类型
selectType(e) {
trackClick('match', 'tab_click', e.currentTarget.dataset.type || '类型选择')
const typeId = e.currentTarget.dataset.type
const type = MATCH_TYPES.find(t => t.id === typeId)
this.setData({
@@ -207,7 +207,6 @@ Page({
// 点击匹配按钮
async handleMatchClick() {
trackClick('match', 'btn_click', '开始匹配')
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
// 导师顾问:先播匹配动画,动画完成后再跳转(不在此处直接跳)
@@ -309,7 +308,7 @@ Page({
confirmText: '去购买',
success: (res) => {
if (res.confirm) {
wx.switchTab({ url: '/pages/chapters/chapters' })
wx.switchTab({ url: '/pages/catalog/catalog' })
}
}
})
@@ -365,7 +364,7 @@ Page({
}, 500)
// 1.5-3秒后导师顾问→跳转其他类型→弹窗
const delay = Math.random() * 7000 + 3000
const delay = Math.random() * 1500 + 1500
setTimeout(() => {
clearInterval(timer)
this.setData({ isMatching: false })
@@ -414,15 +413,14 @@ Page({
// 从数据库获取真实用户匹配
let matchedUser = null
try {
const res = await app.request({
url: '/api/miniprogram/match/users',
silent: true,
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
method: 'POST',
data: {
matchType: this.data.selectedType,
userId: app.globalData.userInfo?.id || ''
}
})
if (res.success && res.data) {
matchedUser = res.data
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
@@ -432,8 +430,8 @@ Page({
}
// 延迟显示结果(模拟匹配过程)
const delay = Math.random() * 7000 + 3000
const timeoutId = setTimeout(() => {
const delay = Math.random() * 2000 + 2000
setTimeout(() => {
clearInterval(timer)
// 如果没有匹配到用户,提示用户
@@ -463,9 +461,39 @@ Page({
// 上报匹配行为到存客宝
this.reportMatch(matchedUser)
}, delay)
},
// 生成模拟匹配数据
generateMockMatch() {
const nicknames = ['创业先锋', '资源整合者', '私域专家', '导师顾问', '连续创业者']
const concepts = [
'专注私域流量运营5年帮助100+品牌实现从0到1的增长。',
'连续创业者,擅长商业模式设计和资源整合。',
'在Soul分享真实创业故事希望找到志同道合的合作伙伴。'
]
const wechats = ['soul_partner_1', 'soul_business_2024', 'soul_startup_fan']
const index = Math.floor(Math.random() * nicknames.length)
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
return {
id: `user_${Date.now()}`,
nickname: nicknames[index],
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
tags: ['创业者', '私域运营', currentType?.label || '创业合伙'],
matchScore: Math.floor(Math.random() * 20) + 80,
concept: concepts[index % concepts.length],
wechat: wechats[index % wechats.length],
commonInterests: [
{ icon: '📚', text: '都在读《创业派对》' },
{ icon: '💼', text: '对私域运营感兴趣' },
{ icon: '🎯', text: '相似的创业方向' }
]
}
},
// 上报匹配行为
async reportMatch(matchedUser) {
try {
@@ -484,15 +512,6 @@ Page({
}
}
})
// 记录匹配行为到 user_tracks
const uid = app.globalData.userInfo?.id
if (uid) {
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId: uid, action: 'match', target: matchedUser?.id || '', extraData: { matchType: this.data.selectedType } },
silent: true
}).catch(() => {})
}
// 匹配后规则:引导填写 MBTI/行业信息
checkAndExecute('after_match', this)
} catch (e) {
@@ -512,7 +531,6 @@ Page({
// 添加微信好友
handleAddWechat() {
trackClick('match', 'btn_click', '加好友')
if (!this.data.currentMatch) return
wx.setClipboardData({
@@ -563,7 +581,6 @@ Page({
// 提交加入
async handleJoinSubmit() {
trackClick('match', 'btn_click', '加入提交')
const { contactType, phoneNumber, wechatId, joinType, isJoining, canHelp, needHelp } = this.data
if (isJoining) return
@@ -619,16 +636,18 @@ Page({
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
} else {
this.setData({
joinSuccess: false,
joinError: res.error || '提交失败,请稍后重试'
})
// 即使API返回失败也模拟成功因为已保存本地
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
}
} catch (e) {
this.setData({
joinSuccess: false,
joinError: e.message || '网络异常,请稍后重试'
})
// 网络错误时也模拟成功
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
} finally {
this.setData({ isJoining: false })
}
@@ -652,7 +671,6 @@ Page({
// 购买匹配次数
async buyMatchCount() {
trackClick('match', 'btn_click', '购买次数')
this.setData({ showUnlockModal: false })
try {
@@ -706,7 +724,19 @@ Page({
if (e.errMsg && e.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消', icon: 'none' })
} else {
wx.showToast({ title: e.message || '支付失败,请稍后重试', icon: 'none' })
// 测试模式
wx.showModal({
title: '支付服务暂不可用',
content: '是否使用测试模式购买?',
success: (res) => {
if (res.confirm) {
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
wx.setStorageSync('extra_match_count', extraMatches)
wx.showToast({ title: '测试购买成功', icon: 'success' })
this.initUserStatus()
}
}
})
}
}
},
@@ -717,6 +747,11 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 打开资料修改页(找伙伴右上角图标)
openSettings() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 阻止事件冒泡
preventBubble() {},

View File

@@ -5,8 +5,7 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const { formatStatNum } = require('../../utils/util.js')
Page({
data: {
@@ -19,7 +18,7 @@ Page({
userInfo: null,
// 统计数据
totalSections: 0,
totalSections: 62,
readCount: 0,
referralCount: 0,
earnings: '-',
@@ -30,12 +29,17 @@ Page({
// 阅读统计
totalReadTime: 0,
matchHistory: 0,
readCountText: '0',
totalReadTimeText: '0',
matchHistoryText: '0',
// 最近阅读
recentChapters: [],
// 功能配置
matchEnabled: false,
referralEnabled: true,
searchEnabled: true,
// VIP状态
isVip: false,
@@ -72,23 +76,25 @@ Page({
contactSaving: false,
pendingWithdraw: false,
// 我的余额wallet 页入口展示)
walletBalance: 0,
// 设置入口:开发版、体验版显示
showSettingsEntry: false,
// 我的代付链接
giftList: [],
// 我的余额
walletBalanceText: '--',
},
onLoad() {
wx.showShareMenu({ withShareTimeline: true })
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const envVersion = accountInfo?.miniProgram?.envVersion || ''
const showSettingsEntry = envVersion === 'develop' || envVersion === 'trial'
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
navBarHeight: app.globalData.navBarHeight,
showSettingsEntry
})
this.loadFeatureConfig()
this.initUserStatus()
// 规则引擎:登录后检查(填头像等)
checkAndExecute('after_login', this)
},
onShow() {
@@ -109,10 +115,14 @@ Page({
try {
const res = await app.request('/api/miniprogram/config')
const features = (res && res.features) || (res && res.data && res.data.features) || {}
this.setData({ matchEnabled: features.matchEnabled === true })
const matchEnabled = features.matchEnabled === true
const referralEnabled = features.referralEnabled !== false
const searchEnabled = features.searchEnabled !== false
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled })
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false })
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
}
},
@@ -138,27 +148,33 @@ Page({
earningsLoading: true,
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
matchHistory: 0,
readCountText: '0',
totalReadTimeText: '0',
matchHistoryText: '0'
})
this.loadDashboardStats()
this.loadMyEarnings()
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadGiftList()
} else {
const guestReadCount = app.getReadCount()
this.setData({
isLoggedIn: false,
userInfo: null,
userIdShort: '',
readCount: app.getReadCount(),
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
referralCount: 0,
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
totalReadTime: 0,
matchHistory: 0
matchHistory: 0,
totalReadTimeText: '0',
matchHistoryText: '0'
})
}
},
@@ -187,10 +203,16 @@ Page({
}))
: []
const readCount = Number(res.data.readCount || 0)
const totalReadTime = Number(res.data.totalReadMinutes || 0)
const matchHistory = Number(res.data.matchHistory || 0)
this.setData({
readCount: Number(res.data.readCount || 0),
totalReadTime: Number(res.data.totalReadMinutes || 0),
matchHistory: Number(res.data.matchHistory || 0),
readCount,
totalReadTime,
matchHistory,
readCountText: formatStatNum(readCount),
totalReadTimeText: formatStatNum(totalReadTime),
matchHistoryText: formatStatNum(matchHistory),
recentChapters
})
} catch (e) {
@@ -466,9 +488,8 @@ Page({
})
})
// 2. 获取上传后的完整URLOSS 返回完整 URL本地返回相对路径
const rawUrl = uploadRes.data.url || ''
const avatarUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://') ? rawUrl : app.globalData.baseUrl + rawUrl
// 2. 获取上传后的完整URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
console.log('[My] 头像上传成功:', avatarUrl)
// 3. 更新本地头像
@@ -643,7 +664,6 @@ Page({
// 显示登录弹窗(每次打开时协议未勾选,符合审核要求)
showLogin() {
trackClick('my', 'btn_click', '登录')
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
@@ -691,7 +711,6 @@ Page({
// 微信登录(须已勾选同意协议,且做好错误处理避免审核报错)
async handleWechatLogin() {
trackClick('my', 'btn_click', '微信登录')
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
@@ -744,20 +763,19 @@ Page({
// 点击菜单
handleMenuTap(e) {
trackClick('my', 'btn_click', e.currentTarget.dataset.id || '菜单')
const id = e.currentTarget.dataset.id
if (!this.data.isLoggedIn && id !== 'about') {
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const routes = {
wallet: '/pages/wallet/wallet',
orders: '/pages/purchases/purchases',
giftPay: '/pages/gift-pay/list',
referral: '/pages/referral/referral',
withdrawRecords: '/pages/withdraw-records/withdraw-records',
about: '/pages/about/about',
wallet: '/pages/wallet/wallet',
settings: '/pages/settings/settings'
}
@@ -776,28 +794,21 @@ Page({
// 跳转到目录
goToChapters() {
trackClick('my', 'nav_click', '目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 跳转到关于页
goToAbout() {
wx.navigateTo({ url: '/pages/about/about' })
},
// 跳转到匹配
goToMatch() {
trackClick('my', 'nav_click', '匹配')
wx.switchTab({ url: '/pages/match/match' })
},
// 跳转到推广中心(需登录)
goToReferral() {
trackClick('my', 'nav_click', '推广')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
if (!this.data.referralEnabled) return
wx.navigateTo({ url: '/pages/referral/referral' })
},
@@ -816,46 +827,6 @@ Page({
})
},
async loadWalletBalance() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id
try {
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
if (res && res.data) {
this.setData({ walletBalance: (res.data.balance || 0).toFixed(2) })
}
} catch (e) {}
},
async loadGiftList() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const userId = app.globalData.userInfo.id
try {
const res = await app.request({ url: `/api/miniprogram/balance/gifts?userId=${userId}`, silent: true })
if (res?.success && res.data?.gifts) {
this.setData({ giftList: res.data.gifts })
}
} catch (e) {}
},
onGiftShareTap(e) {
const giftCode = e.currentTarget.dataset.code
const title = e.currentTarget.dataset.title || '精选文章'
const sectionId = e.currentTarget.dataset.sectionId
this._pendingGiftShare = { giftCode, title, sectionId }
wx.showModal({
title: '分享代付链接',
content: `将「${title}」的免费阅读链接分享给好友`,
confirmText: '立即分享',
cancelText: '取消',
success: (r) => {
if (r.confirm) {
wx.shareAppMessage()
}
}
})
},
// VIP状态查询注意hasFullBook=9.9 买断,不等同 VIP
async loadVipStatus() {
const userId = app.globalData.userInfo?.id
@@ -879,6 +850,18 @@ Page({
} catch (e) { console.log('[My] VIP查询失败', e) }
},
async loadWalletBalance() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
if (res?.success && res.data) {
const balance = res.data.balance || 0
this.setData({ walletBalanceText: balance.toFixed(2) })
}
} catch (e) { console.log('[My] 余额查询失败', e) }
},
// 头像点击:已登录弹出选项(微信头像 / 相册)
onAvatarTap() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -918,8 +901,7 @@ Page({
fail: (e) => reject(e)
})
})
const rawAvatarUrl = uploadRes.data.url || ''
const avatarUrl = rawAvatarUrl.startsWith('http://') || rawAvatarUrl.startsWith('https://') ? rawAvatarUrl : app.globalData.baseUrl + rawAvatarUrl
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
@@ -937,18 +919,22 @@ Page({
},
goToVip() {
trackClick('my', 'nav_click', 'VIP')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
goToProfileEdit() {
trackClick('my', 'nav_click', '设置')
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 进入个人资料展示页enhanced_professional_profile展示页内可再进编辑
goToProfileShow() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
},
async handleWithdraw() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
const amount = parseFloat(this.data.pendingEarnings)
@@ -1047,17 +1033,6 @@ Page({
stopPropagation() {},
onShareAppMessage() {
if (this._pendingGiftShare) {
const { giftCode, title, sectionId } = this._pendingGiftShare
this._pendingGiftShare = null
const ref = app.getMyReferralCode()
let path = `/pages/read/read?id=${sectionId}&gift=${giftCode}`
if (ref) path += `&ref=${ref}`
return {
title: `🎁 好友已为你解锁:${title}`,
path
}
}
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 我的',

View File

@@ -34,7 +34,13 @@
<view class="profile-meta">
<view class="profile-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
<view class="profile-name-actions">
<view class="profile-edit-btn" bindtap="goToProfileShow">
<image class="profile-edit-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
<text class="profile-edit-text">编辑</text>
</view>
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
</view>
</view>
<view class="vip-tags">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
@@ -46,19 +52,19 @@
</view>
<view class="profile-stats-row">
<view class="profile-stat" bindtap="goToChapters">
<text class="profile-stat-val">{{readCount}}</text>
<text class="profile-stat-val">{{readCountText}}</text>
<text class="profile-stat-label">已读章节</text>
</view>
<view class="profile-stat" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
<view class="profile-stat" bindtap="handleMenuTap" data-id="wallet">
<text class="profile-stat-val">{{walletBalance > 0 ? '¥' + walletBalance : '0'}}</text>
<text class="profile-stat-val">{{walletBalanceText}}</text>
<text class="profile-stat-label">我的余额</text>
</view>
</view>
@@ -98,17 +104,17 @@
<view class="stats-grid">
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{readCount}}</text>
<text class="stat-num">{{readCountText}}</text>
<text class="stat-label">已读章节</text>
</view>
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{totalReadTime}}</text>
<text class="stat-num">{{totalReadTimeText}}</text>
<text class="stat-label">阅读分钟</text>
</view>
<view class="stat-box" bindtap="goToMatch">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{matchHistory}}</text>
<text class="stat-num">{{matchHistoryText}}</text>
<text class="stat-label">匹配伙伴</text>
</view>
</view>
@@ -142,27 +148,7 @@
</view>
</view>
<!-- 我的代付链接 -->
<view class="card gift-card" wx:if="{{giftList.length > 0}}">
<view class="card-header">
<image class="card-icon-img" src="/assets/icons/wallet.svg" mode="aspectFit"/>
<text class="card-title">我的代付链接</text>
</view>
<view class="gift-list">
<view class="gift-item" wx:for="{{giftList}}" wx:key="giftCode">
<view class="gift-left">
<text class="gift-title">{{item.sectionTitle}}</text>
<text class="gift-meta">¥{{item.amount}} · {{item.status === 'pending' ? '待领取' : '已领取'}} · {{item.createdAt}}</text>
</view>
<view class="gift-action" wx:if="{{item.status === 'pending'}}" bindtap="onGiftShareTap" data-code="{{item.giftCode}}" data-title="{{item.sectionTitle}}" data-section-id="{{item.sectionId}}">
<text class="gift-share-btn">分享</text>
</view>
<text class="gift-done" wx:else>已送出</text>
</view>
</view>
</view>
<!-- 我的订单 + 关于作者 + 设置 -->
<!-- 我的订单 + 设置 -->
<view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
@@ -171,14 +157,14 @@
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
<view class="menu-item" bindtap="handleMenuTap" data-id="giftPay">
<view class="menu-left">
<view class="menu-icon-wrap icon-blue"><image class="menu-icon-img" src="/assets/icons/info-blue.svg" mode="aspectFit"/></view>
<text class="menu-text">关于作者</text>
<view class="menu-icon-wrap icon-gold"><image class="menu-icon-img" src="/assets/icons/gift.svg" mode="aspectFit"/></view>
<text class="menu-text">我的代付</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
<view class="menu-item" wx:if="{{showSettingsEntry}}" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
<text class="menu-text">设置</text>

View File

@@ -59,6 +59,10 @@
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.profile-name-actions { display: flex; align-items: center; gap: 16rpx; flex-shrink: 0; }
.profile-edit-btn { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 16rpx; background: rgba(255,255,255,0.08); border-radius: 12rpx; }
.profile-edit-icon { width: 28rpx; height: 28rpx; opacity: 0.7; }
.profile-edit-text { font-size: 24rpx; color: rgba(255,255,255,0.7); }
.user-name {
font-size: 44rpx; font-weight: bold; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
@@ -178,10 +182,8 @@
.icon-blue .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-gray { background: rgba(156,163,175,0.15); }
.icon-gray .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-amber { background: rgba(245,158,11,0.2); }
.menu-icon-emoji { font-size: 28rpx; }
.menu-right { display: flex; align-items: center; gap: 12rpx; }
.menu-balance { font-size: 26rpx; color: #4FD1C5; font-weight: 500; }
.icon-gold { background: rgba(200,161,70,0.2); }
.icon-gold .menu-icon-img { width: 32rpx; height: 32rpx; }
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
.menu-arrow { font-size: 36rpx; color: #9CA3AF; }
@@ -251,14 +253,5 @@
.modal-btn-cancel { background: rgba(255,255,255,0.1); color: #fff; }
.modal-btn-confirm { background: #4FD1C5; color: #000; font-weight: 600; }
/* 代付链接卡片 */
.gift-list { display: flex; flex-direction: column; gap: 16rpx; }
.gift-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; }
.gift-left { flex: 1; min-width: 0; }
.gift-title { display: block; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gift-meta { display: block; font-size: 22rpx; color: #9CA3AF; margin-top: 6rpx; }
.gift-share-btn { display: inline-block; padding: 8rpx 28rpx; background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: 600; border-radius: 20rpx; }
.gift-done { font-size: 24rpx; color: #6B7280; }
/* 底部留白:配合 page padding-bottom避免内容被 TabBar 遮挡 */
.bottom-space { height: calc(80rpx + env(safe-area-inset-bottom, 0px)); }

View File

@@ -34,7 +34,7 @@
<text class="doc-p">我们可能适时更新本政策,更新后将通过小程序内公示等方式通知您。继续使用即视为接受更新后的政策。</text>
<text class="doc-section">八、联系我们</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

View File

@@ -18,6 +18,7 @@ Page({
isVip: false,
avatar: '',
nickname: '',
shareCardPath: '', // 分享名片封面图(预生成)
mbti: '',
mbtiIndex: 0,
region: '',
@@ -40,8 +41,15 @@ Page({
showAvatarModal: false,
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
wx.showShareMenu({ withShareTimeline: true })
// 从朋友圈/分享打开且带 id跳转到名片详情member-detail
if (options?.id) {
const ref = options.ref ? `&ref=${options.ref}` : ''
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${options.id}${ref}` })
return
}
this.loadProfile()
},
@@ -85,6 +93,7 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
}
@@ -95,6 +104,167 @@ Page({
goBack() { getApp().goBackOrToHome() },
// 生成分享名片封面图(参考:头像左+昵称右,分隔线,四栏信息 5:4
async generateShareCard() {
const { avatar, nickname, region, mbti, industry, position } = this.data
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const ctx = wx.createCanvasContext('shareCardCanvas', this)
const w = 500
const h = 400
const pad = 32
// 背景(深灰卡片感)
const grd = ctx.createLinearGradient(0, 0, w, h)
grd.addColorStop(0, '#1E293B')
grd.addColorStop(1, '#0F172A')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, w, h)
// 顶部区域:左头像 + 右昵称
const avatarSize = 100
const avatarX = pad + 10
const avatarY = 50
const avatarRadius = avatarSize / 2
const rightStart = avatarX + avatarSize + 28
const drawAvatar = () => new Promise((resolve) => {
if (avatar && avatar.startsWith('http')) {
wx.downloadFile({
url: avatar,
success: (res) => {
if (res.statusCode === 200) {
ctx.save()
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(res.tempFilePath, avatarX, avatarY, avatarSize, avatarSize)
ctx.restore()
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
}
resolve()
},
fail: () => {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
},
})
} else {
this.drawAvatarPlaceholder(ctx, avatarX, avatarY, avatarSize, nickname)
resolve()
}
})
await drawAvatar()
ctx.setStrokeStyle('rgba(94,234,212,0.5)')
ctx.setLineWidth(2)
ctx.beginPath()
ctx.arc(avatarX + avatarRadius, avatarY + avatarRadius, avatarRadius, 0, Math.PI * 2)
ctx.stroke()
// 右侧:昵称 + 个人名片
const displayName = (nickname || '').trim() || '创业者'
ctx.setFillStyle('#ffffff')
ctx.setFontSize(26)
ctx.setTextAlign('left')
ctx.fillText(displayName, rightStart, avatarY + 36)
ctx.setFillStyle('#94A3B8')
ctx.setFontSize(13)
ctx.fillText('个人名片', rightStart, avatarY + 62)
// 分隔线
const divY = 168
ctx.setStrokeStyle('rgba(255,255,255,0.08)')
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(pad, divY)
ctx.lineTo(w - pad, divY)
ctx.stroke()
// 底部四栏:地区 | MBTI行业 | 职位
const labelGray = '#64748B'
const valueWhite = '#F1F5F9'
const rowH = 52
const colW = (w - pad * 2) / 2
const truncate = (text, maxW) => {
if (!text) return ''
ctx.setFontSize(15)
let m = ctx.measureText(text)
if (m.width <= maxW) return text
for (let i = text.length - 1; i > 0; i--) {
const t = text.slice(0, i) + '…'
if (ctx.measureText(t).width <= maxW) return t
}
return text[0] + '…'
}
const maxValW = colW - 16
const items = [
{ label: '地区', value: truncate((region || '').trim() || '未填写', maxValW), x: pad },
{ label: 'MBTI', value: (mbti || '').trim() || '未填写', x: pad + colW },
{ label: '行业', value: truncate((industry || '').trim() || '未填写', maxValW), x: pad },
{ label: '职位', value: truncate((position || '').trim() || '未填写', maxValW), x: pad + colW },
]
items.forEach((item, i) => {
const row = Math.floor(i / 2)
const baseY = divY + 36 + row * rowH
ctx.setFillStyle(labelGray)
ctx.setFontSize(12)
ctx.fillText(item.label, item.x, baseY - 8)
ctx.setFillStyle(valueWhite)
ctx.setFontSize(15)
ctx.fillText(item.value, item.x, baseY + 14)
})
ctx.draw(true, () => {
wx.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
destWidth: 500,
destHeight: 400,
success: (res) => {
this.setData({ shareCardPath: res.tempFilePath })
},
}, this)
})
} catch (e) {
console.warn('[ShareCard] 生成失败:', e)
}
},
drawAvatarPlaceholder(ctx, x, y, size, nickname) {
ctx.setFillStyle('rgba(94,234,212,0.2)')
ctx.beginPath()
ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#5EEAD4')
ctx.setFontSize(size * 0.42)
ctx.setTextAlign('center')
ctx.fillText((nickname || '?')[0], x + size / 2, y + size / 2 + size * 0.14)
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const path = userId
? (ref ? `/pages/member-detail/member-detail?id=${userId}&ref=${ref}` : `/pages/member-detail/member-detail?id=${userId}`)
: (ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit')
const result = {
title: `${nickname}为您分享名片`,
path,
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onShareTimeline() {
const ref = app.getMyReferralCode()
const userId = app.globalData.userInfo?.id
const nickname = (this.data.nickname || '').trim() || '我'
const query = userId
? (ref ? `id=${userId}&ref=${ref}` : `id=${userId}`)
: (ref ? `ref=${ref}` : '')
const result = {
title: `${nickname}为您分享名片`,
query: query || '',
}
if (this.data.shareCardPath) result.imageUrl = this.data.shareCardPath
return result
},
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
onRegionInput(e) { this.setData({ region: e.detail.value }) },
@@ -174,6 +344,7 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
@@ -223,6 +394,7 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })

View File

@@ -146,6 +146,9 @@
<view class="bottom-space"></view>
</scroll-view>
<!-- 分享名片 canvas隐藏用于生成分享图 5:4 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
<!-- 头像弹窗:通过 button 获取微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">

View File

@@ -1,4 +1,9 @@
/* 资料编辑 - comprehensive_profile_editor_v1_1 | 配色 enhancedinput/textarea 用 view 包裹 */
/* 分享名片 canvas隐藏仅用于生成图片 */
.share-card-canvas {
position: fixed; left: -9999px; top: 0; width: 500px; height: 400px;
}
.page {
background: #050B14; min-height: 100vh; color: #fff;
width: 100%; box-sizing: border-box; overflow-x: hidden;
@@ -163,8 +168,11 @@
.btn-choose-avatar {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: #5EEAD4;
color: #050B14;
font-size: 30rpx;

View File

@@ -13,13 +13,12 @@
* - contentSegments 解析每行mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
*/
const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
Page({
@@ -63,7 +62,7 @@ Page({
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 0,
totalSections: 62,
// 弹窗
showShareModal: false,
@@ -72,38 +71,44 @@ Page({
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
showShareTip: false,
_shareTipShown: false,
_lastScrollTop: 0,
// 章节 mid扫码/海报分享用,便于分享 path 带 mid
sectionMid: null
sectionMid: null,
// 余额(用于余额支付)
walletBalance: 0,
},
async onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
// 预加载 linkTags、linkedMiniprograms、persons(供 onLinkTagTap / onMentionTap 和内容自动匹配用
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms || !app.globalData.personsConfig) {
try {
const cfg = await app.request({ url: '/api/miniprogram/config', silent: true })
// 预加载 linkTags、linkedMiniprograms供 onLinkTagTap 用密钥查 appId
if (!app.globalData.linkTagsConfig || !app.globalData.linkedMiniprograms) {
app.request({ url: '/api/miniprogram/config', silent: true }).then(cfg => {
if (cfg) {
if (Array.isArray(cfg.linkTags)) app.globalData.linkTagsConfig = cfg.linkTags
if (Array.isArray(cfg.linkedMiniprograms)) app.globalData.linkedMiniprograms = cfg.linkedMiniprograms
if (Array.isArray(cfg.persons)) app.globalData.personsConfig = cfg.persons
}
} catch (e) {}
}).catch(() => {})
}
// 支持 scene扫码、mid、id、ref
// 支持 scene扫码、mid、id、ref、gift代付
const sceneStr = (options && options.scene) || ''
const parsed = parseScene(sceneStr)
const ref = options.ref || parsed.ref
const isGift = options.gift === '1' || options.gift === 'true'
// 代付统一到代付页gift=1&ref=requestSn 时直接跳转,禁止在阅读页代付
if (isGift && ref) {
wx.redirectTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(ref)}` })
return
}
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
let id = options.id || parsed.id || app.globalData.initialSectionId
const ref = options.ref || parsed.ref
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
console.log("页面:",mid);
// mid 有值但无 id 时,从 bookData 或 API 解析 id
if (mid && !id) {
const bookData = app.globalData.bookData || []
@@ -143,11 +148,6 @@ Page({
app.handleReferralCode({ query: { ref } })
}
const giftCode = options.gift || ''
if (giftCode) {
this._pendingGiftCode = giftCode
}
try {
const config = await accessManager.fetchLatestConfig()
this.setData({
@@ -170,13 +170,6 @@ Page({
// 加载内容(复用已拉取的章节数据,避免二次请求)
await this.loadContent(id, accessState, chapterRes)
// 自动领取礼物码(代付解锁)
if (this._pendingGiftCode && !canAccess && app.globalData.isLoggedIn) {
await this._redeemGiftCode(this._pendingGiftCode)
this._pendingGiftCode = null
return
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
@@ -185,21 +178,6 @@ Page({
// 5. 加载导航
this.loadNavigation(id)
// 6. 规则引擎:阅读前检查(填头像、绑手机等)
checkAndExecute('before_read', this)
// 7. 记录浏览行为到 user_tracks
const userId = app.globalData.userInfo?.id
if (userId) {
app.request('/api/miniprogram/track', {
method: 'POST',
data: { userId, action: 'view_chapter', target: id, extraData: { sectionId: id, mid: mid || '' } },
silent: true
}).catch(() => {})
// 更新全局阅读计数
app.globalData.readCount = (app.globalData.readCount || 0) + 1
}
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
@@ -216,11 +194,6 @@ Page({
return
}
const currentScrollTop = e.scrollTop || 0
const lastScrollTop = this.data._lastScrollTop || 0
const isScrollingDown = currentScrollTop < lastScrollTop
this.setData({ _lastScrollTop: currentScrollTop })
// 获取滚动信息并更新追踪器
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
@@ -239,12 +212,6 @@ Page({
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
: 0
this.setData({ readingProgress: progress })
// 阅读超过20%且向上滑动时,弹出一次分享提示
if (progress >= 20 && isScrollingDown && !this.data._shareTipShown) {
this.setData({ showShareTip: true, _shareTipShown: true })
setTimeout(() => { this.setData({ showShareTip: false }) }, 4000)
}
// 更新阅读追踪器(记录最大进度、判断是否读完)
readingTracker.updateProgress(scrollInfo)
@@ -272,8 +239,7 @@ Page({
// 已解锁用 data.content完整内容未解锁用 content预览先 determineAccessState 再 loadContent 保证顺序正确
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
if (res && displayContent) {
const parserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] }
const { lines, segments } = contentParser.parseContent(displayContent, parserConfig)
const { lines, segments } = contentParser.parseContent(displayContent)
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const updates = {
@@ -297,8 +263,7 @@ Page({
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const cachedParserConfig = { persons: app.globalData.personsConfig || [], linkTags: app.globalData.linkTagsConfig || [] }
const { lines, segments } = contentParser.parseContent(cached.content, cachedParserConfig)
const { lines, segments } = contentParser.parseContent(cached.content)
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
this.setData({
@@ -321,31 +286,49 @@ Page({
// 获取章节信息
getSectionInfo(id) {
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
if (cachedSection) {
return {
id,
title: cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`,
isFree: cachedSection.isFree === true || cachedSection.is_free === true || cachedSection.price === 0,
price: cachedSection.price ?? 1
}
// 特殊章节
if (id === 'preface') {
return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 }
}
if (id === 'epilogue') {
return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 }
}
if (id.startsWith('appendix')) {
const appendixTitles = {
'appendix-1': 'Soul派对房精选对话',
'appendix-2': '创业者自检清单',
'appendix-3': '本书提到的工具和资源'
}
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
}
// 普通章节
return {
id,
id: id,
title: this.getSectionTitle(id),
isFree: false,
isFree: id === '1.1',
price: 1
}
},
// 获取章节标题
getSectionTitle(id) {
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
if (cachedSection) {
return cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`
const titles = {
'1.1': '荷包:电动车出租的被动收入模式',
'1.2': '老墨:资源整合高手的社交方法',
'1.3': '笑声背后的MBTI',
'1.4': '人性的三角结构:利益、情感、价值观',
'1.5': '沟通差的问题:为什么你说的别人听不懂',
'2.1': '相亲故事:你以为找的是人,实际是在找模式',
'2.2': '找工作迷茫者:为什么简历解决不了人生',
'2.3': '撸运费险:小钱困住大脑的真实心理',
'2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力',
'2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒',
'3.1': '3000万流水如何跑出来(退税模式解析)',
'8.1': '流量杠杆:抖音、Soul、飞书',
'9.14': '大健康私域一个月150万的70后'
}
return `章节 ${id}`
return titles[id] || `章节 ${id}`
},
// 根据 id/mid 构造章节接口路径(优先使用 mid。必须带 userId 才能让后端正确判断付费用户并返回完整内容
@@ -519,53 +502,33 @@ Page({
}
}
// CKB 类型:走「链接卡若」留资流程(与首页 onLinkKaruo 一致)
// CKB 类型:复用 @mention 加好友流程,弹出留资表单
if (tagType === 'ckb') {
this._doCkbLead(label)
// 触发通用加好友(无特定 personId使用全局 CKB Key
this.onMentionTap({ currentTarget: { dataset: { userId: '', nickname: label } } })
return
}
// 小程序类型:查 linkedMiniprograms 得 appId降级直接用 mpKey/appId 字段
// 小程序类型:用密钥查 linkedMiniprograms 得 appId再唤醒(需在 app.json 的 navigateToMiniProgramAppIdList 中配置)
if (tagType === 'miniprogram') {
let appId = (e.currentTarget.dataset.appId || '').trim()
if (!mpKey && label) {
const cached = (app.globalData.linkTagsConfig || []).find(t => t.label === label)
if (cached) {
mpKey = cached.mpKey || ''
if (!appId && cached.appId) appId = cached.appId
}
if (cached) mpKey = cached.mpKey || ''
}
const linked = (app.globalData.linkedMiniprograms || []).find(m => m.key === mpKey)
const targetAppId = (linked && linked.appId) ? linked.appId : (appId || mpKey || '')
const selfAppId = (app.globalData.config?.mpConfig?.appId || app.globalData.appId || 'wxb8bbb2b10dec74aa')
const targetPath = pagePath || (linked && linked.path) || ''
if (targetAppId === selfAppId || !targetAppId) {
if (targetPath) {
const navPath = targetPath.startsWith('/') ? targetPath : '/' + targetPath
wx.navigateTo({ url: navPath, fail: () => wx.switchTab({ url: navPath }) })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
return
}
if (targetAppId) {
if (linked && linked.appId) {
wx.navigateToMiniProgram({
appId: targetAppId,
path: targetPath,
appId: linked.appId,
path: pagePath || linked.path || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
console.warn('[LinkTag] 小程序跳转失败:', err)
if (targetPath) {
wx.navigateTo({ url: targetPath.startsWith('/') ? targetPath : '/' + targetPath, fail: () => {} })
} else {
wx.showToast({ title: '跳转失败,请检查小程序配置', icon: 'none' })
}
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
return
}
wx.showToast({ title: '未配置关联小程序', icon: 'none' })
if (mpKey) wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
}
// 小程序内部路径pagePath 或 url 以 /pages/ 开头)
@@ -597,17 +560,9 @@ Page({
// 点击正文中的 @某人:确认弹窗 → 登录/资料校验 → 调用 ckb/lead 加好友留资
onMentionTap(e) {
let userId = e.currentTarget.dataset.userId
const userId = e.currentTarget.dataset.userId
const nickname = (e.currentTarget.dataset.nickname || '').trim() || 'TA'
if (!userId && nickname !== 'TA') {
const persons = app.globalData.personsConfig || []
const match = persons.find(p => p.name === nickname || (p.aliases || '').split(',').map(a => a.trim()).includes(nickname))
if (match) userId = match.personId || ''
}
if (!userId) {
wx.showToast({ title: `暂无 @${nickname} 的信息`, icon: 'none' })
return
}
if (!userId) return
wx.showModal({
title: '添加好友',
content: `是否添加 @${nickname} `,
@@ -638,21 +593,19 @@ Page({
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
}
} catch (e) {}
}
if ((!phone && !wechatId) || !avatar) {
if (!phone && !wechatId) {
wx.showModal({
title: '完善资料',
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
content: '请先填写手机号或微信号,以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
@@ -661,6 +614,12 @@ Page({
})
return
}
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
@@ -678,84 +637,8 @@ Page({
})
wx.hideLoading()
if (res && res.success) {
const who = targetNickname || '对方'
wx.showModal({
title: '提交成功',
content: `${who} 会主动添加你微信,请注意你的微信消息`,
showCancel: false,
confirmText: '好的'
})
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
async _doCkbLead(label) {
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再链接',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const userId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
if (!phone && !wechatId) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || '').trim()
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || '').trim()
if (!avatar) avatar = (profileRes.data.avatar || '').trim()
}
} catch (e) {}
}
if ((!phone && !wechatId) || !avatar) {
wx.showModal({
title: '完善资料',
content: !avatar ? '请先设置头像和填写联系方式,以便对方联系您' : '请先填写手机号或微信号,以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/index-lead',
method: 'POST',
data: {
userId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
source: 'article_ckb_tag',
tagLabel: label || undefined
}
})
wx.hideLoading()
if (res && res.success) {
wx.showModal({
title: '提交成功',
content: '卡若会主动添加你微信,请注意你的微信消息',
showCancel: false,
confirmText: '好的'
})
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
@@ -774,6 +657,41 @@ Page({
this.setData({ showShareModal: false })
},
// 找好友代付:创建代付请求后跳转代付页(美团式,在代付页分享)
async showGiftShareModal() {
if (!app.globalData.userInfo?.id) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const { sectionId, sectionMid } = this.data
const productId = sectionId || ''
if (!productId) {
wx.showToast({ title: '章节信息异常', icon: 'none' })
return
}
wx.showLoading({ title: '创建代付请求...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/gift-pay/create',
method: 'POST',
data: {
userId: app.globalData.userInfo.id,
productType: 'section',
productId
}
})
wx.hideLoading()
if (res && res.success && res.requestSn) {
wx.navigateTo({ url: `/pages/gift-pay/detail?requestSn=${res.requestSn}` })
} else {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '创建失败', icon: 'none' })
}
},
// 复制链接
copyLink() {
const userInfo = app.globalData.userInfo
@@ -791,15 +709,16 @@ Page({
// 复制分享文案(朋友圈风格)
copyShareText() {
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const shareText = title + '\n\n' + picked.join('\n\n')
const { section } = this.data
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看!
#创业派对 #私域运营 #商业案例`
wx.setClipboardData({
data: shareText,
success: () => {
@@ -808,63 +727,39 @@ Page({
})
},
// 分享到微信 - 自动带分享人ID;优先用 mid扫码/海报闭环),无则用 id
// 分享到微信 - 自动带分享人ID
onShareAppMessage() {
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const giftCode = this._giftCodeToShare || ''
this._giftCodeToShare = null
let shareTitle = section?.title
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
const title = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
if (giftCode) shareTitle = `🎁 好友已为你解锁:${section?.title || '精选文章'}`
let path = `/pages/read/read?${q}`
if (ref) path += `&ref=${ref}`
if (giftCode) path += `&gift=${giftCode}`
return { title: shareTitle, path }
return { title, path }
},
// 分享到朋友圈:带文章标题,过长时截断(朋友圈卡片标题显示有限)
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline只能通过右上角菜单分享点击时引导用户
onShareTimelineTap() {
wx.showToast({
title: '请点击右上角「...」→ 分享到朋友圈',
icon: 'none',
duration: 2500
})
},
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const query = ref ? `${q}&ref=${ref}` : q
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? `📚 ${articleTitle.length > 24 ? articleTitle.slice(0, 24) + '...' : articleTitle}Soul创业派对`
: '📚 Soul创业派对 - 真实商业故事'
return { title, query: ref ? `${q}&ref=${ref}` : q }
},
shareToMoments() {
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const copyText = title + '\n\n' + picked.join('\n\n')
wx.setClipboardData({
data: copyText,
success: () => {
wx.showModal({
title: '文案已复制',
content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布',
showCancel: false,
confirmText: '知道了'
})
},
fail: () => {
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
}
})
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: 'Soul创业派对 - 真实商业故事'
return { title, query }
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
@@ -1076,6 +971,39 @@ Page({
wx.showLoading({ title: '正在发起支付...', mask: true })
try {
// 0. 尝试余额支付(若余额足够)
const userId = app.globalData.userInfo?.id
const referralCode = wx.getStorageSync('referral_code') || ''
if (userId) {
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
const balance = balanceRes?.data?.balance || 0
if (balance >= amount) {
const productId = type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : '')
const consumeRes = await app.request({
url: '/api/miniprogram/balance/consume',
method: 'POST',
data: {
userId,
productType: type,
productId: type === 'section' ? sectionId : (type === 'fullbook' ? 'fullbook' : 'vip_annual'),
amount,
referralCode: referralCode || undefined
}
})
if (consumeRes?.success) {
wx.hideLoading()
this.setData({ isPaying: false })
wx.showToast({ title: '购买成功', icon: 'success' })
await this.onPaymentSuccess()
return
}
}
} catch (e) {
console.warn('[Pay] 余额支付失败,改用微信支付:', e)
}
}
// 1. 先获取openId (支付必需)
let openId = app.globalData.openId || wx.getStorageSync('openId')
@@ -1143,18 +1071,15 @@ Page({
console.error('[Pay] API创建订单失败:', apiError)
wx.hideLoading()
// 支付接口失败时,显示客服联系方式
const supportWechat = app.globalData.supportWechat || ''
wx.showModal({
title: '支付通道维护中',
content: supportWechat
? `微信支付正在审核中,请添加客服微信(${supportWechat})手动购买,感谢理解!`
: '微信支付正在审核中,请联系管理员手动购买,感谢理解!',
confirmText: supportWechat ? '复制微信号' : '我知道了',
content: '微信支付正在审核中请添加客服微信28533368手动购买感谢理解',
confirmText: '复制微信号',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm && supportWechat) {
if (res.confirm) {
wx.setClipboardData({
data: supportWechat,
data: '28533368',
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
@@ -1194,18 +1119,15 @@ Page({
wx.showToast({ title: '已取消支付', icon: 'none' })
} else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) {
// 支付失败,可能是参数错误或权限问题
const supportWechat = app.globalData.supportWechat || ''
wx.showModal({
title: '支付失败',
content: supportWechat
? `微信支付暂不可用,请添加客服微信(${supportWechat})手动购买`
: '微信支付暂不可用,请稍后重试或联系管理员',
confirmText: supportWechat ? '复制微信号' : '我知道了',
content: '微信支付暂不可用请添加客服微信28533368手动购买',
confirmText: '复制微信号',
cancelText: '取消',
success: (res) => {
if (res.confirm && supportWechat) {
if (res.confirm) {
wx.setClipboardData({
data: supportWechat,
data: '28533368',
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}
@@ -1358,11 +1280,6 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
showPosterModal() {
this.setData({ showPosterModal: true })
this.generatePoster()
},
// 生成海报
async generatePoster() {
wx.showLoading({ title: '生成中...' })
@@ -1527,160 +1444,7 @@ Page({
closePosterModal() {
this.setData({ showPosterModal: false })
},
closeShareTip() {
this.setData({ showShareTip: false })
},
// 代付分享:微信支付或余额帮好友解锁当前章节
async handleGiftPay() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({ title: '提示', content: '请先登录', confirmText: '去登录', success: (r) => { if (r.confirm) this.showLoginModal() } })
return
}
const sectionId = this.data.sectionId
const userId = app.globalData.userInfo.id
const price = (this.data.section && this.data.section.price != null) ? this.data.section.price : (this.data.sectionPrice || 1)
wx.showModal({
title: '代付分享',
content: `为好友代付本章 ¥${price}\n支付后将生成代付链接,好友点击即可免费阅读`,
confirmText: '确认代付',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) return
wx.showActionSheet({
itemList: ['微信支付', '用余额支付'],
success: async (actionRes) => {
if (actionRes.tapIndex === 0) {
this._giftPayViaWechat(sectionId, userId, price)
} else if (actionRes.tapIndex === 1) {
this._giftPayViaBalance(sectionId, userId, price)
}
}
})
}
})
},
async _giftPayViaWechat(sectionId, userId, price) {
let openId = app.globalData.openId || wx.getStorageSync('openId')
if (!openId) { openId = await app.getOpenId() }
if (!openId) { wx.showToast({ title: '获取支付凭证失败,请重新登录', icon: 'none' }); return }
wx.showLoading({ title: '创建订单...' })
try {
const payRes = await app.request({
url: '/api/miniprogram/pay',
method: 'POST',
data: {
openId: openId,
productType: 'gift',
productId: sectionId,
amount: price,
description: `代付解锁:${this.data.section?.title || sectionId}`,
userId: userId,
}
})
wx.hideLoading()
const params = (payRes && payRes.data && payRes.data.payParams) ? payRes.data.payParams : (payRes && payRes.payParams ? payRes.payParams : null)
if (params) {
wx.requestPayment({
...params,
success: async () => {
wx.showLoading({ title: '生成分享链接...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId, paidViaWechat: true }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
this._giftCodeToShare = giftRes.data.giftCode
wx.showModal({
title: '代付成功',
content: `已为好友代付 ¥${price},分享后好友可免费阅读`,
confirmText: '分享给好友',
cancelText: '稍后分享',
success: (r) => { if (r.confirm) wx.shareAppMessage() }
})
} else {
wx.showToast({ title: '支付成功,请手动分享', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '支付成功,生成链接失败', icon: 'none' })
}
},
fail: () => { wx.showToast({ title: '支付已取消', icon: 'none' }) }
})
} else {
wx.showToast({ title: '创建支付失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
console.error('[GiftPay] WeChat pay error:', e)
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
}
},
async _giftPayViaBalance(sectionId, userId, price) {
const balRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true }).catch(() => null)
const balance = (balRes && balRes.data) ? balRes.data.balance : 0
if (balance < price) {
wx.showModal({
title: '余额不足',
content: `当前余额 ¥${balance.toFixed(2)},需要 ¥${price}\n请先充值`,
confirmText: '去充值',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/wallet/wallet' }) }
})
return
}
wx.showLoading({ title: '处理中...' })
try {
const giftRes = await app.request({
url: '/api/miniprogram/balance/gift',
method: 'POST',
data: { giverId: userId, sectionId }
})
wx.hideLoading()
if (giftRes && giftRes.data && giftRes.data.giftCode) {
this._giftCodeToShare = giftRes.data.giftCode
wx.showModal({
title: '代付成功',
content: `已从余额扣除 ¥${price},分享后好友可免费阅读`,
confirmText: '分享给好友',
cancelText: '稍后分享',
success: (r) => { if (r.confirm) wx.shareAppMessage() }
})
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '代付失败', icon: 'none' })
}
},
// 领取礼物码解锁
async _redeemGiftCode(giftCode) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
try {
const res = await app.request({
url: '/api/miniprogram/balance/gift/redeem',
method: 'POST',
data: { giftCode, receiverId: app.globalData.userInfo.id }
})
if (res && res.data) {
wx.showToast({ title: '好友已为你解锁!', icon: 'success' })
this.onLoad({ id: this.data.sectionId })
}
} catch (e) {
console.warn('[Gift] 领取失败:', e)
}
},
// 保存海报到相册
savePoster() {
wx.canvasToTempFilePath({

File diff suppressed because it is too large Load Diff

View File

@@ -25,12 +25,12 @@
<!-- 阅读内容 -->
<view class="read-content">
<!-- 章节标题 -->
<view class="chapter-header" wx:if="{{section}}">
<view class="chapter-header">
<view class="chapter-meta">
<text class="chapter-id">{{section.id}}</text>
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
</view>
<text class="chapter-title">{{section.title}}</text>
<text class="chapter-title" user-select>{{section.title}}</text>
</view>
<!-- 加载状态 -->
@@ -46,9 +46,9 @@
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<text wx:if="{{seg.type === 'text'}}" user-select>{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" user-select bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" user-select bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
@@ -85,21 +85,18 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<button class="action-btn-inline btn-share-inline" open-type="share">
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
<text class="action-icon-small">📣</text>
<text class="action-text-small">分享给好友</text>
</button>
<view class="action-btn-inline btn-gift-inline" bindtap="handleGiftPay">
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
<text class="action-text-small">分享到朋友圈</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="showPosterModal">
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<text class="action-icon-small">🖼️</text>
<text class="action-text-small">生成海报</text>
</view>
</view>
<view class="share-tip-inline">
<text class="share-tip-text">分享后好友购买,你可获得 90% 收益</text>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn}}">
<text class="action-icon-small">🎁</text>
<text class="action-text-small">代付分享</text>
</view>
</view>
</view>
@@ -109,7 +106,7 @@
<!-- 预览内容 + 付费墙 - 未登录 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_login'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->
@@ -160,7 +157,7 @@
<!-- 预览内容 + 付费墙 - 已登录未购买 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_purchased'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->
@@ -194,6 +191,11 @@
</view>
<text class="paywall-tip">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:让好友帮我买 -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn}}">
<text class="gift-share-icon">🎁</text>
<text class="gift-share-text">找好友代付</text>
</view>
</view>
<!-- 章节导航 -->
@@ -230,7 +232,7 @@
<!-- 错误状态 - 网络异常 -->
<view class="article preview" wx:if="{{accessState === 'error'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
<text user-select>{{item}}</text>
</view>
<!-- 渐变遮罩 -->
@@ -249,14 +251,6 @@
</view>
</view>
<!-- 分享提示浮层阅读20%后下拉触发) -->
<view class="share-float-tip {{showShareTip ? 'show' : ''}}" wx:if="{{showShareTip}}">
<text class="share-float-icon">💰</text>
<text class="share-float-text">分享给好友,好友购买你可获得 90% 收益</text>
<button class="share-float-btn" open-type="share">立即分享</button>
<view class="share-float-close" bindtap="closeShareTip">✕</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">
@@ -312,8 +306,8 @@
</view>
</view>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<text class="fab-moments-icon">🌐</text>
</view>
<!-- 右下角悬浮分享按钮 -->
<button class="fab-share" open-type="share">
<image class="fab-icon" src="/assets/icons/share.svg" mode="aspectFit"></image>
</button>
</view>

View File

@@ -348,23 +348,20 @@
display: flex;
gap: 24rpx;
margin-bottom: 48rpx;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex: 1;
flex: 1 1 0;
min-width: 0;
padding: 24rpx;
border-radius: 24rpx;
max-width: 48%;
box-sizing: border-box;
overflow: hidden;
}
.nav-btn-placeholder {
flex: 1;
flex: 1 1 0;
min-width: 0;
max-width: 48%;
}
.nav-prev {
@@ -405,12 +402,16 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.btn-row {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
overflow: hidden;
}
.btn-arrow {
@@ -438,7 +439,6 @@
.action-btn-inline {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
@@ -460,23 +460,17 @@
}
.btn-poster-inline {
background: linear-gradient(135deg, #2d2d30 0%, #3d3d40 100%);
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
}
.btn-moments-inline {
background: linear-gradient(135deg, #1a4a2e, #0d3320);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.btn-moments-inline:active {
opacity: 0.7;
}
.action-icon-small {
font-size: 40rpx;
font-size: 28rpx;
}
.action-text-small {
font-size: 22rpx;
font-size: 24rpx;
color: #ffffff;
font-weight: 500;
}
@@ -592,6 +586,48 @@
color: rgba(255, 255, 255, 0.6);
}
/* ===== 代付分享 ===== */
.btn-gift-inline {
/* 与 btn-share-inline 同风格 */
}
.gift-share-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 24rpx;
padding: 20rpx;
background: rgba(255, 215, 0, 0.08);
border-radius: 24rpx;
border: 1rpx solid rgba(255, 215, 0, 0.2);
}
.gift-share-icon { font-size: 32rpx; }
.gift-share-text { font-size: 28rpx; color: #FFD700; }
.share-modal-desc {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
display: block;
margin-bottom: 32rpx;
line-height: 1.5;
}
.share-modal-actions {
display: flex;
gap: 24rpx;
}
.share-modal-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 32rpx 24rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 24rpx;
border: 1rpx solid rgba(255, 255, 255, 0.1);
}
.share-modal-btn .btn-icon { font-size: 48rpx; }
.share-modal-btn text:last-child { font-size: 26rpx; color: rgba(255, 255, 255, 0.8); }
/* ===== 分享弹窗 ===== */
.share-link-box {
padding: 32rpx;
@@ -1015,81 +1051,3 @@
display: block;
}
.fab-moments-icon {
font-size: 48rpx;
}
/* ===== 分享提示文字(底部导航上方) ===== */
.share-tip-inline {
text-align: center;
margin-top: 16rpx;
}
.share-tip-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
}
/* ===== 分享浮层提示阅读20%触发) ===== */
.share-float-tip {
position: fixed;
top: 180rpx;
left: 40rpx;
right: 40rpx;
background: linear-gradient(135deg, #1a3a4a 0%, #0d2533 100%);
border: 1rpx solid rgba(0, 206, 209, 0.3);
border-radius: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
z-index: 10000;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(-40rpx);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.share-float-tip.show {
opacity: 1;
transform: translateY(0);
}
.share-float-icon {
font-size: 40rpx;
flex-shrink: 0;
}
.share-float-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
flex: 1;
}
.share-float-btn {
background: linear-gradient(135deg, #00CED1, #20B2AA) !important;
color: #fff !important;
font-size: 24rpx;
padding: 10rpx 28rpx;
border-radius: 32rpx;
border: none;
flex-shrink: 0;
line-height: 1.5;
}
.share-float-btn::after {
border: none;
}
.share-float-close {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.4);
padding: 8rpx;
flex-shrink: 0;
}
/* ===== 代付分享按钮 ===== */
.btn-gift-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 12rpx 24rpx;
border-radius: 16rpx;
background: rgba(255, 165, 0, 0.1);
border: 1rpx solid rgba(255, 165, 0, 0.3);
}

View File

@@ -1,379 +0,0 @@
/* ???????? - 1:1??Web?? */
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
/* ??? */
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-left { display: flex; gap: 16rpx; align-items: center; }
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; flex: 1; text-align: center; }
.nav-right-placeholder { width: 144rpx; }
.nav-btn { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.content { padding: 24rpx; width: 100%; box-sizing: border-box; }
/* ?????? */
.expiring-banner { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 24rpx; margin-bottom: 24rpx; }
.banner-icon { width: 80rpx; height: 80rpx; background: rgba(255,165,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-bell-warning { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(64%) sepia(89%) saturate(1363%) hue-rotate(4deg) brightness(101%) contrast(102%); }
.banner-content { flex: 1; }
.banner-title { font-size: 28rpx; font-weight: 500; color: #fff; display: block; }
.banner-desc { font-size: 24rpx; color: rgba(255,165,0,0.8); margin-top: 4rpx; display: block; }
/* ???? - ?? Next.js */
.earnings-card { position: relative; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden; width: 100%; box-sizing: border-box; }
.earnings-bg { position: absolute; top: 0; right: 0; width: 256rpx; height: 256rpx; background: rgba(0,206,209,0.15); border-radius: 50%; filter: blur(100rpx); }
.earnings-main { position: relative; }
.earnings-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32rpx; }
.earnings-left { display: flex; align-items: center; gap: 16rpx; }
.wallet-icon { width: 80rpx; height: 80rpx; background: rgba(0,206,209,0.2); border-radius: 20rpx; display: flex; align-items: center; justify-content: center; }
.icon-wallet { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.earnings-info { display: flex; flex-direction: column; gap: 8rpx; }
.earnings-label { font-size: 24rpx; color: rgba(255,255,255,0.6); }
.commission-rate { font-size: 24rpx; color: #00CED1; font-weight: 500; }
.earnings-right { text-align: right; }
.earnings-value { font-size: 60rpx; font-weight: 700; color: #fff; display: block; line-height: 1; }
.pending-text { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; display: block; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,206,209,0.3); }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); box-shadow: none; }
/* ???? - ?? Next.js 4??? */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.stat-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.stat-value.orange { color: #FFA500; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-top: 8rpx; display: block; }
/* ????? */
.visit-stat { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 20rpx 32rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 24rpx; }
.visit-label { font-size: 24rpx; color: rgba(255,255,255,0.5); }
.visit-value { font-size: 32rpx; font-weight: 700; color: #00CED1; }
.visit-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* ???? - ?? Next.js */
.rules-card { background: rgba(0,206,209,0.05); border: 2rpx solid rgba(0,206,209,0.2); border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.rules-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.rules-icon { width: 64rpx; height: 64rpx; background: rgba(0,206,209,0.2); border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
.icon-alert { width: 32rpx; height: 32rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.rules-title { font-size: 28rpx; font-weight: 500; color: #fff; }
.rules-list { padding-left: 8rpx; }
.rule-item { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 2; display: block; margin-bottom: 4rpx; }
.rule-item .gold { color: #FFD700; font-weight: 500; }
.rule-item .brand { color: #00CED1; font-weight: 500; }
.rule-item .orange { color: #FFA500; font-weight: 500; }
/* ?????? - ?? Next.js */
.binding-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-header { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-title { display: flex; align-items: center; gap: 12rpx; }
.binding-icon-img { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.title-text { font-size: 30rpx; font-weight: 600; color: #fff; }
.binding-count { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.toggle-icon { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* Tab?? */
.binding-tabs { display: flex; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.tab-item { flex: 1; padding: 24rpx 0; text-align: center; font-size: 26rpx; color: rgba(255,255,255,0.5); position: relative; }
.tab-item.tab-active { color: #00CED1; }
.tab-item.tab-active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80rpx; height: 4rpx; background: #00CED1; border-radius: 4rpx; }
/* ???? */
.binding-list { max-height: 640rpx; overflow-y: auto; }
.empty-state { padding: 80rpx 0; text-align: center; }
.empty-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
.empty-text { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.binding-item { display: flex; align-items: center; padding: 24rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-item:last-child { border-bottom: none; }
.user-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 600; color: #00CED1; margin-right: 24rpx; flex-shrink: 0; }
.user-avatar.avatar-converted { background: rgba(76,175,80,0.2); color: #4CAF50; }
.user-avatar.avatar-expired { background: rgba(158,158,158,0.2); color: #9E9E9E; }
.user-info { flex: 1; }
.user-name { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.user-time { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.user-status { text-align: right; }
.status-amount { font-size: 28rpx; color: #4CAF50; font-weight: 600; display: block; }
.status-order { font-size: 22rpx; color: rgba(255,255,255,0.5); }
.status-tag { font-size: 22rpx; padding: 8rpx 16rpx; border-radius: 16rpx; }
.status-tag.tag-green { background: rgba(76,175,80,0.2); color: #4CAF50; }
.status-tag.tag-orange { background: rgba(255,165,0,0.2); color: #FFA500; }
.status-tag.tag-red { background: rgba(244,67,54,0.2); color: #F44336; }
/* ????? - ?? Next.js */
.invite-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 40rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.invite-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.invite-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.invite-code-box { background: rgba(0,206,209,0.2); padding: 12rpx 24rpx; border-radius: 16rpx; }
.invite-code { font-size: 28rpx; font-weight: 600; color: #00CED1; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
.invite-tip { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 1.5; display: block; }
.invite-tip .gold { color: #FFD700; }
.invite-tip .brand { color: #00CED1; }
/* ?????? - ?? Next.js */
.earnings-detail-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.detail-header { padding: 40rpx 40rpx 24rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.detail-list { max-height: 480rpx; overflow-y: auto; }
.detail-item { display: flex; align-items: center; justify-content: space-between; padding: 32rpx 40rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-item:last-child { border-bottom: none; }
.detail-left { display: flex; align-items: center; gap: 24rpx; flex: 1; }
.detail-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-gift { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.detail-info { flex: 1; }
.detail-type { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.detail-time { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.detail-amount { font-size: 30rpx; font-weight: 600; color: #00CED1; }
/* ???? - ?? Next.js */
.share-section { display: flex; flex-direction: column; gap: 12rpx; width: 100%; margin-bottom: 24rpx; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; border: none; margin: 0; text-align: left; width: 100%; box-sizing: border-box; }
.share-item::after { border: none; }
.share-icon { width: 96rpx; height: 96rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.share-icon.poster { background: rgba(103,58,183,0.2); }
.share-icon.wechat { background: rgba(7,193,96,0.2); }
.share-icon.link { background: rgba(158,158,158,0.2); }
.icon-share-btn { width: 48rpx; height: 48rpx; display: block; }
.share-icon.poster .icon-share-btn { filter: brightness(0) saturate(100%) invert(37%) sepia(73%) saturate(2296%) hue-rotate(252deg) brightness(96%) contrast(92%); }
.share-icon.wechat .icon-share-btn { filter: brightness(0) saturate(100%) invert(58%) sepia(91%) saturate(1255%) hue-rotate(105deg) brightness(96%) contrast(97%); }
.share-icon.link .icon-share-btn { filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-info { flex: 1; text-align: left; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; text-align: left; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; text-align: left; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-btn-wechat { line-height: normal; font-size: inherit; padding: 24rpx 32rpx !important; margin: 0 !important; width: 100% !important; }
/* ?????????????? + ???? + ???????? */
/* ???????? backdrop-filter??????????????? */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 32rpx; box-sizing: border-box; }
.poster-dialog { width: 686rpx; border-radius: 24rpx; overflow: hidden; position: relative; background: transparent; }
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; z-index: 5; font-size: 28rpx; }
/* ???? */
.poster-card { position: relative; background: linear-gradient(135deg, #0a1628 0%, #0f2137 50%, #1a3a5c 100%); color: #fff; padding: 44rpx 40rpx 36rpx; }
.poster-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
/* ???? */
/* ???????? filter: blur ??????????????? + ???? */
.poster-glow { position: absolute; width: 320rpx; height: 320rpx; border-radius: 50%; opacity: 0.6; z-index: 1; }
.poster-glow-left { top: -120rpx; left: -160rpx; background: rgba(0,206,209,0.12); box-shadow: 0 0 140rpx 40rpx rgba(0,206,209,0.18); }
.poster-glow-right { bottom: -140rpx; right: -160rpx; background: rgba(255,215,0,0.10); box-shadow: 0 0 160rpx 50rpx rgba(255,215,0,0.14); }
.poster-ring { position: absolute; width: 520rpx; height: 520rpx; border-radius: 50%; border: 2rpx solid rgba(0,206,209,0.06); left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; }
/* ???? */
.poster-badges { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
.poster-badge { padding: 10rpx 22rpx; font-size: 20rpx; font-weight: 700; border-radius: 999rpx; border: 2rpx solid transparent; }
.poster-badge-gold { background: rgba(255,215,0,0.18); color: #FFD700; border-color: rgba(255,215,0,0.28); }
.poster-badge-brand { background: rgba(0,206,209,0.18); color: #00CED1; border-color: rgba(0,206,209,0.28); }
/* ?? */
.poster-title { margin-bottom: 8rpx; }
.poster-title-line1 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #00CED1; }
.poster-title-line2 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #fff; margin-top: 6rpx; }
.poster-subtitle { display: block; font-size: 22rpx; color: rgba(255,255,255,0.6); margin-bottom: 28rpx; }
/* ???? */
.poster-stats { width: 100%; display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
.poster-stat { flex: 1; max-width: 190rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 16rpx; padding: 18rpx 10rpx; }
.poster-stat-value { display: block; font-size: 44rpx; font-weight: 900; line-height: 1; }
.poster-stat-label { display: block; font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
.poster-stat-gold { color: #FFD700; }
.poster-stat-brand { color: #00CED1; }
.poster-stat-pink { color: #E91E63; }
/* ?? */
.poster-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 10rpx; margin: 0 24rpx 26rpx; }
.poster-tag { font-size: 20rpx; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 12rpx; padding: 6rpx 14rpx; }
/* ??? */
.poster-recommender { display: flex; align-items: center; gap: 12rpx; background: rgba(0,206,209,0.10); border: 2rpx solid rgba(0,206,209,0.20); border-radius: 999rpx; padding: 12rpx 22rpx; margin-bottom: 22rpx; }
.poster-avatar { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: rgba(0,206,209,0.30); display: flex; align-items: center; justify-content: center; }
.poster-avatar-text { font-size: 20rpx; font-weight: 800; color: #00CED1; }
.poster-recommender-text { font-size: 22rpx; color: #00CED1; }
/* ??? */
.poster-discount { width: 100%; background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, rgba(233,30,99,0.10) 100%); border: 2rpx solid rgba(255,215,0,0.20); border-radius: 18rpx; padding: 18rpx 18rpx; margin-bottom: 26rpx; }
.poster-discount-text { font-size: 22rpx; color: rgba(255,255,255,0.80); }
.poster-discount-highlight { color: #00CED1; font-weight: 800; }
/* ??? */
.poster-qr-wrap { background: #fff; padding: 14rpx; border-radius: 16rpx; margin-bottom: 12rpx; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.35); }
.poster-qr-img { width: 240rpx; height: 240rpx; display: block; }
.poster-qr-tip { font-size: 20rpx; color: rgba(255,255,255,0.40); margin-bottom: 8rpx; }
.poster-code { font-size: 22rpx; font-family: monospace; letter-spacing: 2rpx; color: rgba(0,206,209,0.80); }
/* ??????? */
.poster-footer { background: #fff; padding: 28rpx 28rpx 32rpx; display: flex; flex-direction: column; gap: 18rpx; }
.poster-footer-tip { font-size: 22rpx; color: rgba(0,0,0,0.55); text-align: center; }
.poster-footer-btn { height: 72rpx; border-radius: 18rpx; border: 2rpx solid rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: rgba(0,0,0,0.75); background: #fff; }
/* ????- ?? Next.js */
.empty-earnings { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
/* ===== T<><54>rGm<18>5<EFBFBD><35> ?===== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10rpx);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid rgba(56, 189, 172, 0.2);
border-top-color: #38bdac;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.content-loading {
opacity: 0.3;
pointer-events: none;
}
/* ===== <00><>AR<41><6C>^<5E>|<7C>o<EFBFBD>p<EFBFBD><>\!} ===== */
.detail-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.02);
border-radius: 16rpx;
margin-bottom: 16rpx;
transition: all 0.3s;
}
.detail-item:active {
background: rgba(255, 255, 255, 0.05);
}
.detail-avatar-wrap {
flex-shrink: 0;
}
.detail-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 2rpx solid rgba(56, 189, 172, 0.2);
}
.detail-avatar-text {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(135deg, #38bdac 0%, #2da396 100%);

View File

@@ -37,7 +37,7 @@ Page({
// 加载热门章节(从服务器获取点击量高的章节)
async loadHotChapters() {
try {
const res = await app.request('/api/miniprogram/book/hot')
const res = await app.request('/api/miniprogram/book/hot?limit=50')
const list = (res && res.data) || (res && res.chapters) || []
if (list.length > 0) {
const hotChapters = list.map((c, i) => ({

View File

@@ -130,10 +130,10 @@
</view>
<view class="modal-body">
<view class="input-wrapper">
<view class="form-input-wrap">
<input
class="form-input-inner"
type="{{bindType === 'phone' ? 'number' : 'text'}}"
class="form-input"
placeholder="{{bindType === 'phone' ? '请输入11位手机号' : bindType === 'wechat' ? '请输入微信号' : '请输入支付宝账号'}}"
placeholder-class="input-placeholder"
value="{{bindValue}}"
@@ -161,9 +161,9 @@
<view class="modal-close" bindtap="closeSwitchAccountModal">✕</view>
</view>
<view class="modal-body">
<view class="input-wrapper">
<view class="form-input-wrap">
<input
class="form-input"
class="form-input-inner"
placeholder="请输入目标用户的 userId如 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
placeholder-class="input-placeholder"
value="{{switchAccountUserId}}"

View File

@@ -112,9 +112,9 @@
.modal-title { font-size: 36rpx; font-weight: 700; color: #fff; }
.modal-close { width: 64rpx; height: 64rpx; background: rgba(255,255,255,0.08); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32rpx; color: rgba(255,255,255,0.5); }
.modal-body { padding: 16rpx 40rpx 48rpx; }
.input-wrapper { margin-bottom: 32rpx; }
.form-input { width: 100%; padding: 32rpx 24rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; font-size: 32rpx; color: #fff; box-sizing: border-box; transition: all 0.2s; }
.form-input:focus { border-color: rgba(0,206,209,0.5); background: rgba(0,206,209,0.05); }
/* 弹窗 input外边包 viewpadding 写在 view 上,避免光标截断 */
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; margin-bottom: 32rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }
.input-placeholder { color: rgba(255,255,255,0.25); }
.bind-tip { font-size: 24rpx; color: rgba(255,255,255,0.4); margin-bottom: 40rpx; display: block; line-height: 1.6; text-align: center; }
.btn-primary { padding: 32rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #000; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 28rpx; }

View File

@@ -87,7 +87,37 @@ Page({
}
}
this.setData({ purchasing: true })
const amount = this.data.price
try {
// 0. 尝试余额支付(若余额足够)
const referralCode = wx.getStorageSync('referral_code') || ''
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
const balance = balanceRes?.data?.balance || 0
if (balance >= amount) {
const consumeRes = await app.request({
url: '/api/miniprogram/balance/consume',
method: 'POST',
data: {
userId,
productType: 'vip',
productId: 'vip_annual',
amount,
referralCode: referralCode || undefined
}
})
if (consumeRes?.success) {
this.setData({ purchasing: false })
wx.showToast({ title: 'VIP开通成功', icon: 'success' })
await this._onVipPaymentSuccess()
return
}
}
} catch (e) {
console.warn('[VIP] 余额支付失败,改用微信支付:', e)
}
// 1. 微信支付
const payRes = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -95,7 +125,7 @@ Page({
userId,
productType: 'vip',
productId: 'vip_annual',
amount: this.data.price,
amount,
description: '卡若创业派对VIP年度会员365天'
}
})

View File

@@ -3,15 +3,12 @@ const { trackClick } = require('../../utils/trackClick')
Page({
data: {
statusBarHeight: 0,
statusBarHeight: 44,
balance: 0,
balanceText: '0.00',
totalRecharged: '0.00',
totalGifted: '0.00',
totalRefunded: '0.00',
transactions: [],
loading: true,
rechargeAmounts: [10, 30, 50, 1000],
rechargeAmounts: [10, 30, 50, 100],
selectedAmount: 30,
},
@@ -30,9 +27,6 @@ Page({
this.setData({
balance: res.data.balance || 0,
balanceText: (res.data.balance || 0).toFixed(2),
totalRecharged: (res.data.totalRecharged || 0).toFixed(2),
totalGifted: (res.data.totalGifted || 0).toFixed(2),
totalRefunded: (res.data.totalRefunded || 0).toFixed(2),
loading: false,
})
}
@@ -49,9 +43,10 @@ Page({
if (res && res.data) {
const list = (res.data || []).map(t => ({
...t,
amountText: (t.amount || 0).toFixed(2),
amountSign: (t.amount || 0) >= 0 ? '+' : '',
description: t.description || (t.type === 'recharge' ? '充值' : t.type === 'gift' ? '赠送' : t.type === 'refund' ? '退款' : t.type === 'consume' ? '阅读消费' : '其他'),
amountText: Math.abs(t.amount || 0).toFixed(2),
amountSign: (t.amount || 0) >= 0 ? '+' : '-',
description: t.type === 'recharge' ? '充值' : t.type === 'consume' ? '阅读消费' : t.type === 'refund' ? '退款' : '其他',
createdAt: t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : '--',
}))
this.setData({ transactions: list })
}
@@ -109,7 +104,6 @@ Page({
wx.requestPayment({
...params,
success: async () => {
// Confirm the recharge
await app.request({
url: '/api/miniprogram/balance/recharge/confirm',
method: 'POST',
@@ -124,53 +118,16 @@ Page({
}
})
} else {
wx.showToast({ title: '创建支付失败', icon: 'none' })
wx.showToast({ title: payRes?.error || '创建支付失败', icon: 'none' })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Wallet] recharge error:', e)
console.error('[Wallet] recharge error', e)
wx.showToast({ title: '充值失败:' + (e.message || e.errMsg || '网络异常'), icon: 'none', duration: 3000 })
}
},
async handleRefund() {
trackClick('wallet', 'btn_click', '退款')
if (this.data.balance <= 0) {
wx.showToast({ title: '余额为零', icon: 'none' })
return
}
const userId = app.globalData.userInfo.id
const balance = this.data.balance
wx.showModal({
title: '余额退款',
content: `退回全部余额 ¥${balance.toFixed(2)}\n\n退款将在1-3个工作日内原路返回`,
confirmText: '确认退款',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) return
wx.showLoading({ title: '处理中...' })
try {
const result = await app.request({
url: '/api/miniprogram/balance/refund',
method: 'POST',
data: { userId, amount: balance }
})
wx.hideLoading()
if (result && result.data) {
wx.showToast({ title: result.data.message || '退款成功', icon: 'success' })
this.loadBalance()
this.loadTransactions()
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '退款失败', icon: 'none' })
}
}
})
},
goBack() {
wx.navigateBack()
},

View File

@@ -1,6 +1,5 @@
<!-- Soul创业派对 - 我的余额 -->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<text class="back-icon"></text>
@@ -10,7 +9,6 @@
</view>
<view class="nav-placeholder-block" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 余额卡片 -->
<view class="balance-card">
<view class="balance-main" wx:if="{{!loading}}">
<text class="balance-label">当前余额</text>
@@ -22,7 +20,6 @@
</view>
</view>
<!-- 充值金额选择 -->
<view class="section">
<view class="section-head">
<text class="section-title">选择充值金额</text>
@@ -47,23 +44,17 @@
</view>
</view>
<!-- 操作按钮 -->
<view class="action-row">
<view class="btn btn-recharge" bindtap="handleRecharge">充值</view>
</view>
<!-- 充值与消费记录 -->
<view class="section">
<view class="section-head">
<text class="section-title">充值/消费记录</text>
<text class="section-note">按时间倒序显示</text>
</view>
<view class="transactions" wx:if="{{transactions.length > 0}}">
<view
class="tx-item"
wx:for="{{transactions}}"
wx:key="id"
>
<view class="tx-item" wx:for="{{transactions}}" wx:key="id">
<view class="tx-icon {{item.type}}">
<text wx:if="{{item.type === 'recharge'}}">💰</text>
<text wx:elif="{{item.type === 'gift'}}">🎁</text>
@@ -73,7 +64,7 @@
</view>
<view class="tx-info">
<text class="tx-desc">{{item.description}}</text>
<text class="tx-time">{{item.createdAt || item.created_at || '--'}}</text>
<text class="tx-time">{{item.createdAt || '--'}}</text>
</view>
<text class="tx-amount {{item.amount >= 0 ? 'tx-amount-plus' : 'tx-amount-minus'}}">{{item.amountSign}}¥{{item.amountText}}</text>
</view>

View File

@@ -5,7 +5,6 @@
padding-bottom: 64rpx;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
@@ -46,7 +45,6 @@
width: 100%;
}
/* 余额卡片 - 渐变背景 */
.balance-card {
margin: 24rpx 24rpx 32rpx;
background: linear-gradient(135deg, #1c1c1e 0%, rgba(56, 189, 172, 0.15) 100%);
@@ -86,7 +84,6 @@
color: rgba(255, 255, 255, 0.4);
}
/* 区块标题 */
.section {
margin: 0 24rpx 32rpx;
}
@@ -107,7 +104,6 @@
color: rgba(255, 255, 255, 0.45);
}
/* 金额选择卡片 */
.amount-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -168,7 +164,6 @@
color: rgba(213, 255, 250, 0.72);
}
/* 操作按钮 */
.action-row {
display: flex;
gap: 24rpx;
@@ -188,13 +183,7 @@
background: #38bdac;
color: #0a0a0a;
}
.btn-refund {
background: #1c1c1e;
color: rgba(255, 255, 255, 0.9);
border: 2rpx solid rgba(56, 189, 172, 0.4);
}
/* 交易记录 */
.transactions {
background: #1c1c1e;
border-radius: 24rpx;

View File

@@ -23,6 +23,20 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/gift-pay/detail",
"pathName": "pages/gift-pay/detail",
"query": "requestSn=GPRMP20260317114238341300",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/read/read",
"pathName": "pages/read/read",
"query": "mid=219",
"launchMode": "default",
"scene": null
},
{
"name": "唤醒",
"pathName": "pages/read/read",

View File

@@ -170,10 +170,10 @@ class ReadingTracker {
const userId = app.globalData.userInfo?.id
if (!userId) return
// 计算本次上报的时长
// 计算本次上报的时长(仅发送增量 delta后端会累加避免重复累加导致阅读分钟数异常
const now = Date.now()
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += duration
const delta = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += delta
this.activeTracker.lastScrollTime = now
try {
@@ -183,7 +183,7 @@ class ReadingTracker {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
duration: Math.max(0, delta),
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}

View File

@@ -1,6 +1,7 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
@@ -85,6 +86,7 @@ function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
}
// 稳定版:跳转 avatar-nickname与 _ensureProfileCompletedAfterLogin 一致)
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
const user = getUserInfo()
@@ -100,7 +102,7 @@ function checkRule_FillAvatar(rules) {
title: info?.title || '完善个人信息',
message: info?.description || '设置头像和昵称,让其他创业者更容易认识你',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
target: '/pages/avatar-nickname/avatar-nickname'
}
}
@@ -138,11 +140,12 @@ function checkRule_FillProfile(rules) {
}
}
// 稳定版兼容readCount 用 getReadCount()
function checkRule_ShareAfter5Chapters(rules) {
if (!isRuleEnabled(rules, '累计浏览5章节')) return null
const user = getUserInfo()
if (!user.id) return null
const readCount = app.globalData.readCount || 0
const readCount = (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 0))
if (readCount < 5) return null
if (isInCooldown('share_after_5')) return null
setCooldown('share_after_5')
@@ -156,11 +159,12 @@ function checkRule_ShareAfter5Chapters(rules) {
}
}
// 稳定版兼容hasPurchasedFull 用 hasFullBook
function checkRule_FillVipInfo(rules) {
if (!isRuleEnabled(rules, '完成付款')) return null
const user = getUserInfo()
if (!user.id) return null
if (!app.globalData.hasPurchasedFull) return null
if (!(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) return null
if (isInCooldown('fill_vip_info')) return null
setCooldown('fill_vip_info')

View File

@@ -9,11 +9,11 @@ const app = getApp()
*/
function trackClick(module, action, target, extra) {
const userId = app.globalData.userInfo?.id || ''
if (!userId) return
app.request('/api/miniprogram/track', {
app.request({
url: '/api/miniprogram/track',
method: 'POST',
data: {
userId,
userId: userId || undefined,
action,
target,
extraData: Object.assign({ module, page: module }, extra || {})

View File

@@ -32,6 +32,18 @@ const formatMoney = (amount, decimals = 2) => {
return Number(amount).toFixed(decimals)
}
/**
* 格式化统计数字≥1万显示 x.xw≥1千显示 x.xk否则原样
* @param {number} n - 原始数字
* @returns {string}
*/
const formatStatNum = n => {
const num = Number(n) || 0
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(num)
}
// 防抖函数
const debounce = (fn, delay = 300) => {
let timer = null
@@ -175,6 +187,7 @@ module.exports = {
formatTime,
formatDate,
formatMoney,
formatStatNum,
formatNumber,
debounce,
throttle,

Some files were not shown because too many files have changed in this diff Show More