Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/profile-edit/profile-edit.js
#	miniprogram/pages/profile-edit/profile-edit.wxml
#	miniprogram/pages/settings/settings.js
#	miniprogram/utils/ruleEngine.js
#	soul-admin/src/pages/distribution/DistributionPage.tsx
#	soul-admin/src/pages/users/UsersPage.tsx
#	soul-api/.env.production
#	soul-api/.gitignore
#	soul-api/internal/handler/db_ckb_leads.go
#	soul-api/internal/handler/miniprogram.go
#	soul-api/internal/handler/referral.go
#	开发文档/1、需求/archive/链接人与事-存客宝同步-需求规划.md
#	开发文档/1、需求/archive/链接人与事-实现方案.md
This commit is contained in:
Alex-larget
2026-03-20 14:48:02 +08:00
247 changed files with 8990 additions and 6983 deletions

View File

@@ -0,0 +1,32 @@
# 挖矿病毒排查与服务器操作 Skills 创建
**日期**2026-03-20
## 背景
基于 agent 记录3b9e0fa0、bc781e1b、1c1a81c3 等中挖矿病毒排查经验以及本地部署脚本devloy.py、master.py、soul-admin/deploy.py、Cunkebao/miner_guard_install.py 等),将经验吸收转化为 Skills。
## 新增 Skills
### 1. security-miner-guard
- **路径**`.cursor/skills/security-miner-guard/SKILL.md`
- **触发词**挖矿病毒、xmrig、服务器被入侵、miner_guard、安全排查、杀挖矿
- **内容**挖矿病毒特征、入侵链路、排查脚本、加固建议、miner_guard 安装与检查
### 2. security-server-ops
- **路径**`.cursor/skills/security-server-ops/SKILL.md`
- **触发词**部署、服务器操作、SSH、宝塔、devloy、master、Cunkebao 部署
- **内容**:服务器索引、部署脚本索引、环境变量一览、常用操作(不含明文密码)
## 配置更新
- `paths.py`:新增 `AGENT_SECURITY``ROLE_TO_AGENT["安全工程师"]`
- `老板分身-索引.mdc`:经验自动收集推断增加「挖矿/安全/服务器操作→安全工程师」
- `soul-project-boundary.mdc`:按语义触发词增加安全工程师及对应 Skills
## 安全提醒
- Skills 中**不写入明文密码**,仅说明配置来源(环境变量、脚本 get_cfg()
- 建议将 master.py、devloy.py 等中的默认密码迁移到环境变量

View File

@@ -0,0 +1,26 @@
# 管理端部署触发词约定
**日期**2026-03-20
## 场景
用户说「管理端帮我部署到xx环境」时安全工程师应语义化解析 xx直接执行对应部署脚本。
## 解决方案
- **触发词**管理端帮我部署到xx环境语义化理解意图即可
- **脚本映射**
- 含「正式」「线上」「生产」→ `cd soul-admin && python master.py`(正式环境,/www/wwwroot/self/soul-admin
- 含「测试」「dev」→ `cd soul-admin && python deploy.py`(测试环境,/www/wwwroot/self/soul-admin-dev
## soul-admin 部署脚本
| 脚本 | 环境 | 目标目录 | 构建命令 |
|------|------|----------|----------|
| master.py | 正式 | soul-admin | pnpm build |
| deploy.py | 测试 | soul-admin-dev | pnpm run build:dev |
## 已升级 Skills
1. **security-server-ops**何时使用表、2.2 soul-admin 脚本索引、4.4/4.5 常用操作
2. **soul-project-boundary**:按场景触发词表新增

View File

@@ -0,0 +1,18 @@
# 部署 API 触发词约定
**日期**2026-03-20
## 场景
用户说「帮我部署api到线上」时安全工程师应直接执行部署脚本无需再询问或选择。
## 解决方案
- **触发词**帮我部署api到线上
- **动作**:直接执行 `cd soul-api && python master.py`
- **脚本**`soul-api/master.py`soul-api 正式环境部署)
## 已升级 Skills
1. **security-server-ops**:何时使用表新增该触发词,明确直接执行命令
2. **soul-project-boundary**:按场景触发词表新增,加载 security-server-ops 后执行

View File

@@ -0,0 +1,9 @@
# 安全工程师 经验索引
> 挖矿病毒排查、服务器加固、部署与运维相关经验。
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-20 | 挖矿病毒排查经验转化为 Skills服务器操作 Skill 创建 | 2026-03-20-挖矿与服务器Skills.md |
| 2026-03-20 | 「帮我部署api到线上」→ 执行 soul-api/master.py | 2026-03-20-部署API触发词.md |
| 2026-03-20 | 「管理端帮我部署到xx环境」→ 语义化解析正式→master.py测试→deploy.py | 2026-03-20-管理端部署触发词.md |

View File

@@ -0,0 +1,41 @@
# 原生按钮覆盖定位,避免样式干扰
**日期**2026-03-19
**场景**:小程序中需在头像、图片等区域触发 `open-type="chooseAvatar"` 等原生能力,用 `<button>` 包裹会导致原生样式(灰色矩形、边框等)影响界面。
**方案****不用 button 包裹**,改为 **button 绝对定位覆盖** 在目标区域上方。
## 正确结构
```html
<!-- 外层position: relative 作为定位参考 -->
<view class="avatar-wrap">
<!-- 实际展示内容:头像、徽章等 -->
<view class="avatar-inner">...</view>
<view class="vip-badge">VIP</view>
<!-- 透明 button 覆盖在上方,同级而非包裹 -->
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
```
```css
.avatar-wrap { position: relative; }
.avatar-overlay-btn {
position: absolute; top: 0; left: 0;
width: 130rpx; height: 130rpx; /* 与头像一致 */
padding: 0; margin: 0;
background: transparent; border: none;
}
.avatar-overlay-btn::after { border: none; }
```
## 要点
- **同级关系**button 与展示元素是 sibling不是 parent-child
- **绝对定位**`position: absolute` 覆盖在目标区域上,不参与文档流
- **透明无内容**button 仅负责点击事件,样式完全透明
- **适用**chooseAvatar、open-type 等需 button 触发的原生能力
## 升级 Skill
已写入 `miniprogram-dev` SKILL §12。

View File

@@ -0,0 +1,39 @@
# 手机号一键登录与登录弹窗公用组件
> 日期2026-03-20 | 角色:小程序开发工程师
## 问题与场景
1. **getPhoneNumber 不弹窗**:点击手机号登录按钮时,无法弹出手机号选择界面,也无法获取手机号
2. **登录弹窗重复**read、my、gift-pay/detail 三处各自维护一套登录弹窗,逻辑重复、维护成本高
3. **登录后手机号未同步**:手机号登录后若响应中 user.phone 为空,本地 userInfo 未更新
## 解决方案
### 1. getPhoneNumber 必须与隐私协议耦合
- **open-type**`open-type="getPhoneNumber|agreePrivacyAuthorization"`(基础库 2.32.3+
- **onNeedPrivacyAuthorization**app.js 中需将使用 getPhoneNumber 的页面加入支持列表,否则会 `resolve({ event: 'disagree' })` 导致获取失败
- **支持页面**avatar-nickname、profile-edit、read、my、gift-pay/detail、index、settings
- **隐私弹窗**:当 onNeedPrivacyAuthorization 触发时,页面需有 `showPrivacyModal` + `<button open-type="agreePrivacyAuthorization">` 供用户同意
### 2. 登录弹窗公用组件
- **组件路径**`components/login-modal/`
- **使用方式**`<login-modal show="{{showLoginModal}}" desc="..." showPrivacyModal="{{showPrivacyModal}}" showCancel="{{true}}" bind:close="..." bind:success="..." bind:privacyagree="..." />`
- **适用页面**read、my、gift-pay/detail
- **app.json**:全局注册 `"login-modal": "/components/login-modal/login-modal"`
### 3. 登录后手机号同步
- **loginWithPhone 后**:若 `user.phone` 为空,调用 `_syncPhoneFromProfileAfterLogin(userId)` 从 profile 拉取最新
- **checkLoginStatus 恢复缓存时**:若 `userInfo.phone` 为空,调用 `_refreshUserInfoIfPhoneEmpty()` 静默刷新
## 管理后台配置
- **隐私保护指引**:小程序管理后台 → 设置 → 用户隐私保护指引,必须声明「收集你选择的手机号」
- **主体限制**:个人主体小程序无法使用 getPhoneNumber
## 升级 Skill
- miniprogram-dev SKILL §8 补充 getPhoneNumber 耦合与 onNeedPrivacyAuthorization 页面支持

View File

@@ -13,3 +13,5 @@
| 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) |
| 2026-03-19 | 原生按钮覆盖定位chooseAvatar 等用绝对定位 overlay 覆盖,禁止 button 包裹,避免原生样式影响 | [2026-03-19-原生按钮覆盖定位.md](./2026-03-19-原生按钮覆盖定位.md) |
| 2026-03-20 | 手机号一键登录与公用组件getPhoneNumber 耦合 agreePrivacyAuthorizationlogin-modal 组件;登录后手机号同步 | [2026-03-20-手机号登录与公用组件.md](./2026-03-20-手机号登录与公用组件.md) |

View File

@@ -53,6 +53,10 @@
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存parts/hot/recommended/stats/config/章节 content容灾回退 DBOSS 上传;/health 返回 database/redis 状态 |
| 2026-03-18 | 小程序、团队 | 业务规则/最佳实践 | - | 分享链路兼容好友/朋友圈 singlePage单页模式能力降级不支付/不自动领取),引导点击底部“前往小程序”进入完整版 |
| 2026-03-18 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
| 2026-03-19 | 小程序 | 最佳实践 | miniprogram-dev SKILL §11 | 原生按钮覆盖定位chooseAvatar 等用绝对定位 overlay 覆盖,禁止 button 包裹,避免原生样式影响(灰色矩形等) |
| 2026-03-20 | 安全工程师 | 触发词约定 | security-server-ops、soul-project-boundary | 「帮我部署api到线上」→ 直接执行 soul-api/master.py |
| 2026-03-20 | 安全工程师 | 触发词约定 | security-server-ops、soul-project-boundary | 「管理端帮我部署到xx环境」→ 语义化解析正式→master.py测试→deploy.py |
| 2026-03-20 | 小程序 | 最佳实践 | miniprogram-dev SKILL §8 | 手机号登录getPhoneNumber 需耦合 agreePrivacyAuthorizationonNeedPrivacyAuthorization 支持页面;登录弹窗公用组件 login-modal |
---
@@ -63,4 +67,4 @@
---
**最后更新**2026-03-18
**最后更新**2026-03-20

View File

@@ -6,7 +6,7 @@
## 项目总结
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/需求汇总.md`,项目推进表在 `开发文档/10、项目管理/项目落地推进表.md`,临时需求/分析在 `临时需求池/`
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/`(按日期命名,以最新为主;见 `1、需求/索引.md`,项目推进表在 `开发文档/10、项目管理/项目落地推进表.md`,临时需求/分析在 `临时需求池/`
---

View File

@@ -31,9 +31,11 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person幂等键绑定 userId默认资料 flags 后端输出 | 已完成 |
| 2026-03-20 | 「帮我部署api到线上」→ 安全工程师执行 soul-api/master.pysecurity-server-ops、soul-project-boundary 触发词升级 | 已完成 |
| 2026-03-20 | 「管理端帮我部署到xx环境」→ 语义化解析:正式/线上/生产→master.py测试/dev→deploy.pysoul-admin 部署脚本索引 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-18
**最后更新**2026-03-20

View File

@@ -41,9 +41,11 @@
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
| 2026-03-18 | 吸收经验:分享链路需兼容好友/朋友圈 singlePage单页模式能力降级并引导“前往小程序”进入完整版 | 已完成 |
| 2026-03-18 | 会议:支付超级个体前/开通后资料默认校验,跳转 avatar-nickname 引导页(仅头像+昵称) | 已完成 |
| 2026-03-19 | 吸收经验原生按钮覆盖定位chooseAvatar 用绝对定位 overlay 覆盖头像,禁止 button 包裹,已升级 SKILL §11 | 已完成 |
| 2026-03-20 | 手机号一键登录getPhoneNumber 耦合 agreePrivacyAuthorizationonNeedPrivacyAuthorization 支持 read/my/gift-pay/index/settings登录弹窗公用组件 components/login-modal登录后手机号同步 _syncPhoneFromProfileAfterLogin | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-18
**最后更新**2026-03-20

View File

@@ -53,6 +53,7 @@ AGENT_BACKEND = AGENT / "后端工程师"
AGENT_PRODUCT = AGENT / "产品经理"
AGENT_TEST = AGENT / "软件测试"
AGENT_TEAM = AGENT / "团队"
AGENT_SECURITY = AGENT / "安全工程师"
# ========== 常用文件 ==========
RULE_MAIN = RULES / "老板分身-索引.mdc"
@@ -80,6 +81,9 @@ ROLE_TO_AGENT = {
"软件测试": "软件测试",
"测试": "软件测试",
"测试人员": "软件测试",
# 安全
"安全工程师": "安全工程师",
"安全": "安全工程师",
# 通用
"团队": "团队",
}

View File

@@ -12,5 +12,5 @@ alwaysApply: false
### 行为摘要(供模型快速理解,完整流程以 SKILL 文件为准)
1. **文档同步**:从对话中提炼结论/待办/变更 → 写入 `开发文档/1、需求/需求汇总.md`、`开发文档/10、项目管理/运营与变更.md`、`临时需求池/` 等对应文档
1. **文档同步**:从对话中提炼结论/待办/变更 → 写入 `开发文档/1、需求/YYYY-MM-DD-需求.md`(当日文件,以日期最新为主)、`开发文档/10、项目管理/运营与变更.md`、`临时需求池/` 等对应文档
2. **经验入库**:提炼经验 → 写入 `agent/{角色}/evolution/YYYY-MM-DD.md` → 更新 `agent/开发助理/项目索引/{索引名}.md`(写日期)→ 更新 `agent/开发助理/经验清单.md` → 升级对应 SKILL

View File

@@ -20,6 +20,13 @@ alwaysApply: true
| 预览/参考 | next-project/ | 仅预览,非线上 | 不依赖 |
| **新版管理端** | **new-soul/soul-admin/** | 新版参考实现,迁移时对照 | soul-api |
## 需求目录与命名约定
- **需求目录**`开发文档/1、需求/`
- **命名**`YYYY-MM-DD-需求.md` 或 `YYYY-MM-DD-简短描述.md`
- **主需求**:以日期最新的需求文件为主;同步需求时新建/更新当日文件,并更新 `1、需求/索引.md`
- **基准**`以界面定需求.md` 为界面级需求基准,新增/改版界面或业务规则时先更新该文档
## 核心原则
- 小程序只调 `/api/miniprogram/*`;管理端只调 `/api/admin/*`、`/api/db/*`;禁止混用。
@@ -50,6 +57,7 @@ alwaysApply: true
| 小程序、miniprogram、C 端、微信小程序 | 小程序开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\miniprogram-dev\SKILL.md` |
| 产品、需求、验收、排期、需求文档 | 产品经理 | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md` |
| 测试、测试用例、回归测试、功能测试、QA | 测试人员 | `e:\Gongsi\Mycontent\.cursor\skills\testing\SKILL.md` |
| 挖矿、安全、服务器操作、部署、miner_guard、xmrig、入侵排查 | 安全工程师 | `e:\Gongsi\Mycontent\.cursor\skills\security-miner-guard\SKILL.md`、`e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md` |
### 按场景触发词
@@ -63,5 +71,7 @@ alwaysApply: true
| 会议结束、散会、会开完了 | `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`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
| **帮我部署api到线上** | `e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md`(安全工程师 → 执行 soul-api/master.py |
| **管理端帮我部署到xx环境** | `e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md`(安全工程师 → 语义化解析 xx正式/线上/生产→master.py测试/dev→deploy.py |
**注意**:「必须 Read」= 使用 Read 工具读取**绝对路径**的完整文件内容后执行,不可跳过或仅凭记忆。

View File

@@ -39,6 +39,7 @@ alwaysApply: true
- 产品/需求/config→**产品经理**
- 测试/自检/QA→**软件测试**
- 架构/选型/路由约定/三端协同→**团队**
- 挖矿/安全/服务器操作/部署/入侵排查→**安全工程师**
- 无法判断→**通用**(写入开发助理)
3. **若可写文件**
- **有明确目标角色**:写入 `.cursor/agent/{角色}/evolution/YYYY-MM-DD-简短描述.md`,并更新该目录下的 `索引.md`

View File

@@ -30,7 +30,7 @@ description: 开发团队文档同步与经验升级。小橙、橙子、讨论
| 要点类型 | 写入位置 | 示例 |
|----------|----------|------|
| 需求清单项 | `开发文档/1、需求/需求汇总.md` 需求清单表 | 会员分润差异化、VIP 手动设置 |
| 需求清单项 | `开发文档/1、需求/YYYY-MM-DD-需求.md`(当日文件,以日期最新为主) | 会员分润差异化、VIP 手动设置;同步后更新 `1、需求/索引.md` |
| 近期讨论 | `开发文档/10、项目管理/运营与变更.md` | 第五部分或新增节 |
| 技术分析 | `临时需求池/``开发文档/8、部署/` | 分润需求-技术分析.md |
| 项目推进 | `开发文档/10、项目管理/项目落地推进表.md` | 第十二节永平落地表 |
@@ -83,6 +83,7 @@ description: 开发团队文档同步与经验升级。小橙、橙子、讨论
| 后端开发 | `agent/后端工程师/evolution/` | `agent/开发助理/项目索引/后端.md` |
| 产品经理 | `agent/产品经理/evolution/` | `agent/开发助理/项目索引/产品.md` |
| 测试人员 | `agent/软件测试/evolution/` | `agent/开发助理/项目索引/测试.md` |
| 安全工程师 | `agent/安全工程师/evolution/` | `agent/开发助理/项目索引/团队.md` |
| 助理橙子 | `agent/开发助理/evolution/` | `agent/开发助理/项目索引/助理橙子.md` |
| 跨角色/团队 | `agent/团队/evolution/` | `agent/开发助理/项目索引/团队.md` |
@@ -129,7 +130,10 @@ description: 开发团队文档同步与经验升级。小橙、橙子、讨论
```
开发文档/
├── 1、需求/需求汇总.md # 需求清单、业务需求
├── 1、需求/
│ ├── 索引.md # 主需求 = 日期最新的需求文件
│ ├── YYYY-MM-DD-需求.md # 需求文件按日期命名,以最新为主
│ └── 以界面定需求.md # 界面级需求基准
├── 8、部署/ # 技术方案、部署说明
├── 10、项目管理/
│ ├── 项目落地推进表.md # 里程碑、永平落地
@@ -152,7 +156,7 @@ description: 开发团队文档同步与经验升级。小橙、橙子、讨论
**小橙**执行:
1. 提炼VIP 手动设置已完成;会员分润差异化待实现;好友优惠仅针对文章
2. 更新 `需求汇总.md`:新增「需求清单」行
2. 更新 `1、需求/YYYY-MM-DD-需求.md`(当日文件):新增「需求清单」行;同步后更新 `1、需求/索引.md`
3. 更新 `运营与变更.md`:第五部分追加近期讨论
4. 回复:已记录并更新开发文档,详见 xxx

View File

@@ -79,6 +79,8 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
## 8. 平台合规与能力检测2025 起)
- **隐私按需授权**:涉及用户信息的接口(登录、手机号、位置等)必须在用户**实际触发功能时**再请求授权,禁止在 `app.onLaunch` 中集中请求。需配置《小程序用户隐私保护指引》,使用 `<button open-type="agreePrivacyAuthorization">` 获取同意。
- **getPhoneNumber 必须耦合**`open-type="getPhoneNumber|agreePrivacyAuthorization"`(基础库 2.32.3+),否则无法弹窗获取手机号。同时需在 app.js 的 `onNeedPrivacyAuthorization` 中将该页面加入支持列表,并提供 `showPrivacyModal` + 同意按钮,否则会 resolve(disagree) 导致失败。
- **登录弹窗**:使用公用组件 `components/login-modal`read/my/gift-pay 等页面引入,避免重复实现。
- **能力检测**:使用新 API 前用 `wx.canIUse('api.xxx')` 检测,低版本做降级。
- **Skyline可选**:性能敏感页面可在 `page.json` 中配置 `"renderer": "skyline"`,仍使用 WXML/WXSS。
@@ -104,7 +106,24 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
---
## 11. 何时使用本 Skill
## 11. 原生按钮覆盖定位(避免样式干扰)
- **场景**:在头像、图片等区域需触发 `open-type="chooseAvatar"` 等原生能力时,**禁止用 button 包裹**目标元素,否则会受原生样式影响(灰色矩形、边框等)。
- **正确做法**:用 **button 绝对定位覆盖** 在目标区域上方,与展示元素为同级关系。
- **结构示例**
```html
<view class="avatar-wrap">
<view class="avatar-inner">...</view>
<view class="vip-badge">VIP</view>
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
```
- **样式**`.avatar-wrap { position: relative; }``.avatar-overlay-btn { position: absolute; top: 0; left: 0; width/height 与目标一致; background: transparent; border: none; }``::after { border: none; }`。
- **口诀**:同级覆盖,绝对定位,透明按钮。
---
## 12. 何时使用本 Skill
- 在 **miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
@@ -114,5 +133,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
- 做阅读、文章等需长按复制的文本时(遵循 §9text 加 user-select
- 做编辑资料页分享名片时(遵循 §10
- 做头像上传、chooseAvatar 等需 button 触发的原生能力时(遵循 §11用绝对定位覆盖禁止 button 包裹)。
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -192,10 +192,10 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
2. 排除:技术债、规则不清、与稳定版冲突的部分
3. 按**最小功能**拆分,保证每个任务迁移后能完整运行
4. 排期顺序:**界面修改优先** → 大逻辑排后P0逻辑不通→ P1功能缺失→ P2优化
5. 写入需求汇总,形成迁移任务清单
5. 写入需求清单(当日需求文件),形成迁移任务清单
**产出**
- `开发文档/1、需求/需求汇总.md` 追加需求
- `开发文档/1、需求/YYYY-MM-DD-需求.md` 追加需求(当日文件,以日期最新为主;同步后更新 `1、需求/索引.md`
- `开发文档/新版迁移-开发方案与清单.md` 或等价迁移清单
---
@@ -259,7 +259,7 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
4. **逻辑分层**:每个功能过三层(界面/接口/数据)
5. **体验评估**:补充空态、错误态、边界处理
6. **接口规范与冲突**:产出接口规范与冲突清单
7. **抽取需求**:写入需求汇总,形成迁移任务清单
7. **抽取需求**:写入需求清单(当日需求文件),形成迁移任务清单
8. **需求评审(迁移前必做)**:列出功能点 + 样式变更,逐一确认,产出评审清单
9. **回复用户**:给出分析摘要 + 文档路径 + 建议执行顺序;**迁移须在需求评审通过后开始**
@@ -273,7 +273,7 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
| 接口规范与冲突 | `开发文档/新版迁移-接口规范与冲突清单.md` |
| 迁移方案/清单 | `开发文档/新版迁移-开发方案与清单.md``新版功能迁移到稳定版方案.md` |
| **需求评审清单** | `开发文档/新版迁移-需求评审清单.md`(功能点 + 样式变更,含确认状态) |
| 需求汇总 | `开发文档/1、需求/需求汇总.md` |
| 需求清单 | `开发文档/1、需求/YYYY-MM-DD-需求.md`(以日期最新为主) |
若已有同名文档,在其基础上**追加或更新**,不重复创建。

View File

@@ -42,7 +42,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
### 0.3 功能规划与协调变更
1. **输出需求分析**:写入 `临时需求池/YYYY-MM-DD-需求简述.md` 或追加到 `需求汇总.md` 需求清单
1. **输出需求分析**:写入 `临时需求池/YYYY-MM-DD-需求简述.md` 或追加到 `开发文档/1、需求/YYYY-MM-DD-需求.md` 需求清单(以日期最新为主,同步后更新 `1、需求/索引.md`
2. **三端任务拆分**:按上表列出「小程序任务」「管理端任务」「后端任务」
3. **协调变更**:若需更新《以界面定需求》,同步更新界面清单与业务逻辑
4. **指派**:明确各任务对应角色(小程序开发工程师、管理端开发工程师、后端工程师),并给出执行顺序建议(通常:后端 → 小程序;管理端视依赖可并行或后置)
@@ -73,7 +73,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
| 职责 | 说明 | 产出 |
|------|------|------|
| 需求分析 | 业务需求拆解、优先级、技术可行性 | 需求分析文档、临时需求池 |
| 需求文档 | 需求清单、业务规则、验收标准 | 需求汇总.md、运营与变更.md |
| 需求文档 | 需求清单、业务规则、验收标准 | 1、需求/YYYY-MM-DD-需求.md以最新为主、运营与变更.md |
| 验收 | 功能验收、回归检查 | 验收清单、项目推进表 |
| 协调 | 与开发沟通、排期、变更 | 运营与变更、项目落地推进表 |
@@ -83,7 +83,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
| 文档 | 说明 |
|------|------|
| `开发文档/1、需求/需求汇总.md` | 需求清单、业务需求 |
| `开发文档/1、需求/` | 需求清单(按日期命名,以最新为主)、业务需求;见 `1、需求/索引.md` |
| `临时需求池/` | 需求分析、技术分析 |
| `开发文档/10、项目管理/项目落地推进表.md` | 里程碑、永平落地 |
| `开发文档/10、项目管理/运营与变更.md` | 近期讨论、变更记录 |

View File

@@ -0,0 +1,112 @@
---
description: Soul 挖矿病毒排查与防护。xmrig、kdevtmpfsi、kinsing、minerd、miner_guard。Use when 挖矿病毒、xmrig、服务器被入侵、miner_guard、安全排查、杀挖矿.
---
# SKILL - 挖矿病毒排查与防护(安全工程师)
> 基于 Soul 项目历史排查经验沉淀,用于快速识别、清理挖矿病毒并加固服务器。
## 何时使用
| 触发词 | 动作 |
|--------|------|
| 挖矿病毒、xmrig、服务器被入侵 | 执行排查与清理流程 |
| miner_guard、安装挖矿守护 | 安装/检查 miner_guard |
| 安全排查、杀挖矿 | 按本 Skill 执行 |
---
## 一、挖矿病毒特征Soul 项目实测)
### 1.1 进程/路径关键词
| 关键词 | 说明 |
|--------|------|
| xmrig | 门罗币挖矿程序,最常见 |
| xmr-stak, minerd, cpuminer | 其他 CPU 挖矿 |
| kdevtmpfsi, kinsing | Linux 常见挖矿木马 |
| stratum | 矿池协议 |
| libprocesshider, watchbog, ddgs, trace | 隐藏/持久化相关 |
### 1.2 常见路径
- `/tmp/xmrig``/tmp/config.json``/tmp/.x`
- `/tmp/kdevtmpfsi``/tmp/kinsing`
- `/www/wwwroot/**/xmrig*`(宝塔站点目录下残留)
- `/www/wwwroot/self/wanzhi/tongzhi/xmrig-*`(历史发现)
### 1.3 入侵链路Soul 项目根因分析)
```
公网访问宝塔 9988 → 弱口令/漏洞 → 进入面板 → www 用户执行命令
→ 下载 xmrig 到 /tmp → 运行挖矿
```
**最可能入口**:宝塔面板 9988 对公网开放 + 弱口令/历史漏洞。
---
## 二、排查与清理脚本soul-api 目录)
| 脚本 | 用途 |
|------|------|
| `miner_guard_check.py` | 检查 miner_guard 安装状态,手动执行一次脚本,查看日志 |
| `miner_guard_install.py` | 安装挖矿守护到服务器(上传 miner_guard.sh + 配置 cron/systemd |
| `miner_guard.sh` | 守护脚本本体(杀进程、删文件、扫 /tmp、/www/wwwroot |
| `remove_xmrig_self.py` | 删除固定路径 `/www/wwwroot/self/wanzhi/tongzhi/xmrig-6.24.0` |
### 2.1 快速检查(本地执行)
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_check.py
```
依赖:`pip install paramiko`。配置来源:`master.py``get_cfg()` 或环境变量 `DEPLOY_HOST``DEPLOY_PASSWORD` 等。
### 2.2 安装挖矿守护
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_install.py --yes
```
- 上传 `miner_guard.sh``/root/miner_guard.sh`
- 优先写入 `/etc/cron.d/miner_guard`(每 30 分钟),失败则尝试 crontab 或 systemd timer
- 日志:`/var/log/miner_guard.log`
### 2.3 Cunkebao 服务器
```powershell
cd e:\Gongsi\Mycontent\Cunkebao
python miner_guard_install.py --yes
```
使用 Cunkebao 内嵌配置(非 root 用户,日志在 `~/miner_guard.log`)。
---
## 三、加固建议(防止再次入侵)
| 优先级 | 措施 |
|--------|------|
| 1 | 宝塔面板9988 仅允许指定 IP 访问;改非常规端口(如 29988强密码 |
| 2 | 修改所有密码root、宝塔、宝塔 API、MySQL、Redis |
| 3 | Redis`bind 127.0.0.1`,设置 `requirepass` |
| 4 | 敏感信息:密码/API Key 用环境变量,不提交到 Git |
---
## 四、miner_guard.sh 行为摘要
1. **杀进程**`pgrep -f xmrig|kdevtmpfsi|kinsing|...``kill -9`
2. **删已知路径**`/tmp/xmrig``/tmp/config.json`
3. **扫 /tmp、/var/tmp、/dev/shm**:含挖矿关键词的可执行文件 → `rm -f`
4. **扫 /www/wwwroot**:含 xmrig 的目录/文件 → `rm -rf` / `rm -f`
5. **检查 www 用户 crontab**:可疑项仅提示,不自动删
---
## 五、注意事项
- 脚本会按关键词删除,可能与业务目录重叠,部署前确认扫描范围
- `master.py``devloy.py` 等含默认密码,应改为环境变量并确保 `.env` 不入库

View File

@@ -0,0 +1,147 @@
---
description: Soul 服务器操作与部署。部署脚本、SSH、宝塔、环境变量。Use when 部署、服务器操作、SSH、宝塔、devloy、master、Cunkebao 部署.
---
# SKILL - 服务器操作与部署(安全工程师)
> Soul 项目部署脚本索引与服务器操作规范。**密码等敏感信息仅通过环境变量或脚本内 get_cfg() 读取,不在此文档明文列出。**
## 何时使用
| 触发词 | 动作 |
|--------|------|
| **帮我部署api到线上** | **直接执行 `cd soul-api && python master.py`** |
| **管理端帮我部署到xx环境** | **语义化解析 xx直接执行**:含「正式」「线上」「生产」→ `cd soul-admin && python master.py`含「测试」「dev」→ `cd soul-admin && python deploy.py` |
| 部署、服务器操作、SSH | 按本 Skill 选择对应脚本 |
| 宝塔、devloy、master | 查阅部署配置与命令 |
| Cunkebao 部署 | 使用 Cunkebao 专用脚本 |
---
## 一、服务器与配置来源
### 1.1 配置读取优先级
各部署脚本统一约定:
1. **环境变量**(推荐):`DEPLOY_HOST``DEPLOY_USER``DEPLOY_PASSWORD``DEPLOY_SSH_PORT``BT_API_KEY`
2. **脚本内 get_cfg()**:无环境变量时使用脚本默认值
3. **master.py**soul-api 的 miner_guard_check、remove_xmrig_self 等从 `soul-api/master.py``get_cfg()` 读取
### 1.2 服务器索引(配置来源,不含明文密码)
| 项目 | 主机 | 端口 | 用户 | 配置来源 |
|------|------|------|------|----------|
| soul-api 正式 | 43.139.27.93 | 22022 | root | master.py / 环境变量 |
| soul-api 测试 | 43.139.27.93 | 22022 | root | devloy.py / 环境变量 |
| soul-admin 正式 | 43.139.27.93 | 22022 | root | soul-admin/master.py / 环境变量 |
| soul-admin 测试 | 43.139.27.93 | 22022 | root | soul-admin/deploy.py / 环境变量 |
| Cunkebao | 42.194.245.239 | 6523 | yongpxu | Cunkebao/miner_guard_install.py 内嵌 |
**密码**:从 `DEPLOY_PASSWORD` 或各脚本 `get_cfg()` 默认值读取,**不在此文档记录**。
---
## 二、部署脚本索引
### 2.1 soul-api
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-api/devloy.py` | 测试环境部署binary/docker/runner | `python devloy.py --mode runner` |
| `soul-api/master.py` | 正式环境部署 | `python master.py` |
| `soul-api/deploy/runner-init.sh` | Runner 容器首次初始化 | `bash deploy/runner-init.sh` |
| `soul-api/deploy/docker-deploy-remote.sh` | 服务器上执行蓝绿切换 | 由 devloy 自动调用 |
| `soul-api/deploy/deploy-runner-remote.sh` | Runner 模式部署包拷贝 | 由 devloy 自动调用 |
**devloy 模式**
- `--mode runner`:容器内红蓝切换,宝塔固定 9001
- `--mode docker`:宿主机蓝绿,需 Nginx 切换
- `--mode binary`Go 二进制 + 宝塔 soulDev
### 2.2 soul-admin
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-admin/master.py` | 正式环境部署soul-adminpnpm build | `python master.py` |
| `soul-admin/deploy.py` | 测试环境部署soul-admin-devpnpm build:dev | `python deploy.py` |
### 2.3 挖矿防护
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-api/miner_guard_install.py` | 安装挖矿守护soul 服务器) | `python miner_guard_install.py --yes` |
| `Cunkebao/miner_guard_install.py` | 安装挖矿守护Cunkebao | `cd Cunkebao && python miner_guard_install.py --yes` |
---
## 三、环境变量一览
| 变量 | 说明 | 默认来源 |
|------|------|----------|
| DEPLOY_HOST | SSH 主机 | 各脚本 get_cfg() |
| DEPLOY_USER | SSH 用户 | root |
| DEPLOY_PASSWORD | SSH 密码 | 脚本默认 / 需设置 |
| DEPLOY_SSH_KEY | SSH 私钥路径 | 空 |
| DEPLOY_SSH_PORT | SSH 端口 | 22022 |
| BT_PANEL_URL | 宝塔面板 URL | https://{host}:9988 |
| BT_API_KEY | 宝塔 API 密钥 | 脚本内默认 |
| BT_GO_PROJECT_NAME | 宝塔 Go 项目名 | soulDev / soulApi |
| DEPLOY_DOCKER_PATH | 部署目录 | /www/wwwroot/self/soul-dev |
| DEPLOY_NGINX_CONF | Nginx 配置路径 | 空(可自动探测) |
---
## 四、常用操作
### 4.1 SSH 连接(示例,密码从环境变量读取)
```powershell
# 设置环境变量后
$env:DEPLOY_HOST="43.139.27.93"
$env:DEPLOY_PASSWORD="<从安全存储读取>"
ssh -p 22022 root@43.139.27.93
```
### 4.2 部署 soul-api 测试环境Runner 模式)
```powershell
cd e:\Gongsi\Mycontent\soul-api
python devloy.py --mode runner
```
### 4.3 部署 soul-api 正式环境
```powershell
cd e:\Gongsi\Mycontent\soul-api
python master.py
```
### 4.4 部署 soul-admin 正式环境
```powershell
cd e:\Gongsi\Mycontent\soul-admin
python master.py
```
### 4.5 部署 soul-admin 测试环境
```powershell
cd e:\Gongsi\Mycontent\soul-admin
python deploy.py
```
### 4.6 检查挖矿守护
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_check.py
```
---
## 五、安全提醒
- **不要将密码提交到 Git**master.py、devloy.py 等中的默认密码应迁移到环境变量
- **宝塔 API 密钥**BT_API_KEY 若泄露需在宝塔面板重新生成
- **敏感文件**`.env``master.py` 等应加入 `.gitignore` 或使用 `.env.example` 模板

Binary file not shown.

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
挖矿病毒守护 - Cunkebao 服务器安装脚本
使用 devlop.py 中的服务器配置,上传 miner_guard.sh 并配置每 30 分钟执行。
运行: cd Cunkebao && python miner_guard_install.py --yes
"""
import os
import sys
import io
try:
import paramiko
except ImportError:
print("错误: pip install paramiko")
sys.exit(1)
# Cunkebao 服务器配置(与 devlop.py 一致)
script_dir = os.path.dirname(os.path.abspath(__file__))
cfg = {
"host": "42.194.245.239",
"port": 6523,
"user": "yongpxu",
"password": "Aa123456789.",
}
def main():
import argparse
p = argparse.ArgumentParser(description="Cunkebao 挖矿守护安装")
p.add_argument("--yes", "-y", action="store_true", help="跳过确认")
args = p.parse_args()
# miner_guard.sh 来自 soul-api
local_sh = os.path.join(os.path.dirname(script_dir), "soul-api", "miner_guard.sh")
if not os.path.isfile(local_sh):
local_sh = os.path.join(script_dir, "miner_guard.sh")
if not os.path.isfile(local_sh):
print("[错误] 未找到 miner_guard.sh请确保 soul-api/miner_guard.sh 存在")
sys.exit(1)
print("=" * 60)
print(" 挖矿病毒守护 - Cunkebao 安装")
print("=" * 60)
print(" 服务器: %s:%s" % (cfg["host"], cfg["port"]))
print(" 用户: %s" % cfg["user"])
print(" 脚本: ~/miner_guard.sh")
print(" 日志: ~/miner_guard.log")
print(" Cron: 每 30 分钟执行")
print("=" * 60)
if not args.yes:
print("\n确认安装? 输入 yes 继续: ", end="")
if input().strip().lower() != "yes":
print("已取消")
return
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(cfg["host"], port=cfg["port"], username=cfg["user"],
password=cfg["password"], timeout=15)
except Exception as e:
print("[连接失败]", str(e))
sys.exit(1)
# 获取用户 home 并确定实际路径
sin, sout, serr = client.exec_command("echo $HOME", timeout=5)
home = sout.read().decode("utf-8", errors="replace").strip() or "/home/%s" % cfg["user"]
remote_path = "%s/miner_guard.sh" % home
log_path = "%s/miner_guard.log" % home
cron_line = "*/30 * * * * /bin/bash %s >> %s 2>&1" % (remote_path, log_path)
# 非 root 时用 $HOME 做日志,避免 /var/log 无权限
with open(local_sh, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
if cfg["user"] != "root":
head = '[ "$(id -u)" != "0" ] && LOG="$HOME/miner_guard.log" && LOCK="/tmp/miner_guard_$(whoami).lock"\n'
if "LOG=" in content and "id -u" not in content[:200]:
content = head + content
sftp = client.open_sftp()
with sftp.file(remote_path, "w") as f:
f.write(content)
sftp.chmod(remote_path, 0o755)
sftp.close()
# 用户 crontab
crontab_line = cron_line + "\n"
tmp_cron = "/tmp/miner_guard_cron_%s" % os.getpid()
sftp = client.open_sftp()
with sftp.file(tmp_cron, "w") as f:
f.write(crontab_line)
sftp.close()
sin, sout, serr = client.exec_command(
"(crontab -l 2>/dev/null | grep -v miner_guard || true; cat %s) | crontab - 2>&1; rm -f %s; crontab -l 2>/dev/null" % (tmp_cron, tmp_cron),
timeout=10
)
out = sout.read().decode("utf-8", errors="replace")
err = serr.read().decode("utf-8", errors="replace")
if "miner_guard" in out:
print("\n[成功] 已安装,每 30 分钟执行")
else:
print("\n[警告] crontab 可能未添加,错误: %s" % (err or out))
print(" 请 SSH 登录后执行: (crontab -l 2>/dev/null; echo '%s') | crontab -" % cron_line.strip())
client.close()
print("\n日志: %s" % log_path)
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 小程序入口
* 卡若创业派对 - 小程序入口
* 开发: 卡若
*/
@@ -13,8 +13,8 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
App({
globalData: {
// API 基础地址:开发时修改下面一行切换环境
baseUrl: "https://soulapi.quwanzhi.com",
// baseUrl: 'http://localhost:8080', // 开发
// baseUrl: "https://soulapi.quwanzhi.com",
baseUrl: 'http://localhost:8080', // 开发
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,
@@ -80,10 +80,31 @@ App({
supportWechat: '',
// config 统一缓存5min减少重复请求
configCache: null,
configCacheExpires: 0
configCacheExpires: 0,
// VIP 联系方式检测上次检测时间戳onShow 节流 5 分钟
lastVipContactCheck: 0,
// 头像昵称检测上次检测时间戳onShow 节流 5 分钟
lastAvatarNicknameCheck: 0,
},
onLaunch(options) {
// 昵称等隐私组件需先授权input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用
if (typeof wx.onNeedPrivacyAuthorization === 'function') {
wx.onNeedPrivacyAuthorization((resolve) => {
this._privacyResolve = resolve
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
const route = (cur && cur.route) || ''
const needPrivacyPages = ['avatar-nickname', 'profile-edit', 'read', 'my', 'gift-pay/detail', 'index', 'settings']
const needShow = needPrivacyPages.some(p => route.includes(p))
if (cur && typeof cur.setData === 'function' && needShow) {
cur.setData({ showPrivacyModal: true })
} else {
resolve({ event: 'disagree' })
}
})
}
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 加载 iconfont字体图标。注意小程序不支持在 wxss 里用本地 @font-face 引用字体文件,
// 需使用 loadFontFace 动态加载(字体文件建议走 https CDN
@@ -103,6 +124,11 @@ App({
// 检查登录状态
this.checkLoginStatus()
// 每次进入:先获取 VIP 状态VIP 走 profile-edit非 VIP 走头像/昵称引导(由 checkVipContactRequiredAndGuide 内部链式调用)
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1500)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
// 加载书籍数据
this.loadBookData()
@@ -143,6 +169,23 @@ App({
this.globalData.lastMpConfigCheck = now
this.getAuditMode()
}
// 从后台切回:先 VIP 强制跳转,再头像/昵称,节流 5 分钟
const throttle = 5 * 60 * 1000
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
if (!this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > throttle) {
this.globalData.lastVipContactCheck = now
this.globalData.lastAvatarNicknameCheck = now
setTimeout(() => this.checkVipContactRequiredAndGuide(), 500)
}
// 从后台切回:若 WSS 已断开则重连(微信后台可能回收连接)
try {
const need = !this._wsSocketTask || (this._wsSocketTask.readyState !== 0 && this._wsSocketTask.readyState !== 1)
if (need) {
this.clearWsReconnect()
setTimeout(() => this.connectWsHeartbeat(), 1000)
}
} catch (_) {}
}
},
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环
@@ -322,6 +365,44 @@ App({
return false
},
/** 判断头像/昵称是否未完善(默认状态) */
_needsAvatarNickname(user) {
const u = user || this.globalData.userInfo || {}
const avatar = (u.avatar || u.avatarUrl || '').trim()
const nickname = (u.nickname || u.nickName || '').trim()
return !avatar || avatar.includes('default') || !nickname || nickname === '微信用户' || nickname.startsWith('微信用户')
},
/**
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit避免乱跳
*/
checkAvatarNicknameAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
if (this.globalData.isVip) return // VIP 统一走 profile-edit此处不触发
if (!this._needsAvatarNickname()) return
try {
const pages = getCurrentPages()
const last = pages[pages.length - 1]
const route = (last && last.route) || ''
if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return
} catch (_) {}
// 老用户:弹窗提示后跳转
const today = new Date().toISOString().slice(0, 10)
const lastDate = wx.getStorageSync('lastAvatarGuideDate') || ''
if (lastDate === today) return
wx.setStorageSync('lastAvatarGuideDate', today)
wx.showModal({
title: '完善个人资料',
content: '请设置头像和昵称,让其他创业者更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
})
},
// 检查登录状态
checkLoginStatus() {
try {
@@ -335,12 +416,219 @@ App({
this.globalData.hasFullBook = userInfo.hasFullBook || false
this.globalData.isVip = userInfo.isVip || false
this.globalData.vipExpireDate = userInfo.vipExpireDate || ''
// 若手机号为空,后台静默刷新用户资料以同步最新手机号(可能在其他设备/页面已绑定)
if (!(userInfo.phone || '').trim()) {
this._refreshUserInfoIfPhoneEmpty()
}
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
},
/**
* 手机号登录后:若响应中 user.phone 为空,从 profile 拉取最新资料并更新本地(后端已写入 DB
*/
async _syncPhoneFromProfileAfterLogin(userId) {
try {
if (!userId) return
const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const profile = res?.data
if (!profile) return
const phone = (profile.phone || '').trim()
if (!phone) return
const updated = { ...this.globalData.userInfo, phone }
if (profile.wechatId != null) updated.wechatId = profile.wechatId
this.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
wx.setStorageSync('user_phone', phone)
} catch (_) {}
},
/**
* 当本地 userInfo.phone 为空时,静默拉取 profile 并更新(用户可能在设置页或其他入口已绑定手机号)
*/
async _refreshUserInfoIfPhoneEmpty() {
try {
const userId = this.globalData.userInfo?.id
if (!userId) return
const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const profile = res?.data
if (!profile) return
const phone = (profile.phone || '').trim()
if (!phone) return
const updated = { ...this.globalData.userInfo, phone }
if (profile.wechatId != null) updated.wechatId = profile.wechatId
this.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
if (phone) wx.setStorageSync('user_phone', phone)
} catch (_) {
// 静默失败,不影响主流程
}
},
/**
* WSS 在线心跳(占位):登录后连接 ws发送 auth + 心跳,供管理端统计在线人数
* 容错任意异常均不向外抛出不影响登录、API 请求等核心功能
*/
clearWsReconnect() {
try {
if (this._wsReconnectTimerId) {
clearTimeout(this._wsReconnectTimerId)
this._wsReconnectTimerId = null
}
this._wsReconnectDelay = 3000
} catch (_) {}
},
scheduleWsReconnect() {
try {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
if (this._wsReconnectTimerId) return
const delay = this._wsReconnectDelay || 3000
this._wsReconnectTimerId = setTimeout(() => {
this._wsReconnectTimerId = null
this._wsReconnectDelay = Math.min(60000, (this._wsReconnectDelay || 3000) * 2)
this.connectWsHeartbeat()
}, delay)
} catch (_) {}
},
connectWsHeartbeat() {
try {
this.clearWsReconnect()
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const userId = this.globalData.userInfo.id
const base = (this.globalData.baseUrl || '').replace(/\/$/, '')
if (!base) return
const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram'
if (this._wsHeartbeatTimer) {
clearInterval(this._wsHeartbeatTimer)
this._wsHeartbeatTimer = null
}
if (this._wsSocketTask) {
try { this._wsSocketTask.close() } catch (_) {}
this._wsSocketTask = null
}
let task
try {
task = wx.connectSocket({
url: wsUrl,
fail: () => { try { this.scheduleWsReconnect() } catch (_) {} }
})
} catch (e) {
if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 连接失败(静默):', e?.message || e)
try { this.scheduleWsReconnect() } catch (_) {}
return
}
task.onOpen(() => {
try {
this.clearWsReconnect()
task.send({ data: JSON.stringify({ type: 'auth', userId }) })
this._wsHeartbeatTimer = setInterval(() => {
try {
if (task && task.readyState === 1) task.send({ data: JSON.stringify({ type: 'heartbeat' }) })
} catch (_) {}
}, 30000)
} catch (_) {}
})
task.onClose(() => {
try {
if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null }
this._wsSocketTask = null
this.scheduleWsReconnect()
} catch (_) {}
})
task.onError(() => {
try {
if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null }
this._wsSocketTask = null
this.scheduleWsReconnect()
} catch (_) {}
})
this._wsSocketTask = task
} catch (e) {
if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 心跳异常(静默,不影响业务):', e?.message || e)
}
},
/**
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit避免与 avatar-nickname 乱跳。
* 旧数据VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const now = Date.now()
if (this._lastVipGuideRun && now - this._lastVipGuideRun < 3000) return // 3 秒内不重复执行,避免 onLaunch+onShow 双重触发
this._lastVipGuideRun = now
const userId = this.globalData.userInfo.id
try {
const pages = getCurrentPages()
const last = pages[pages.length - 1]
const route = (last && last.route) || ''
if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return
} catch (_) {}
try {
const [vipRes, profileRes] = await Promise.all([
this.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }).catch(() => null),
this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }).catch(() => null)
])
const isVip = vipRes?.data?.isVip || this.globalData.isVip || false
this.globalData.isVip = isVip
if (!isVip) {
this.checkAvatarNicknameAndGuide()
return
}
const profileData = profileRes?.data || this.globalData.userInfo || {}
const phone = (profileData.phone || this.globalData.userInfo?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
const needsAvatarNickname = this._needsAvatarNickname(profileData)
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit弹窗「为了更好服务请完善资料」
if (needsAvatarNickname) {
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请完善资料',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
if (phone && wechatId) return
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '完善资料',
content: 'VIP会员需完善手机号以便使用找伙伴、提现等功能',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
// 有手机号但缺微信号:弹窗引导(非强制)
wx.showModal({
title: '完善联系方式',
content: '请到资料页完善微信号,便于他人联系您',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
} catch (e) {
console.log('[App] checkVipContactRequiredAndGuide 失败:', e?.message)
}
},
// 加载书籍元数据totalSections不再预加载 all-chapters
async loadBookData() {
try {
@@ -659,8 +947,17 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎
checkAndExecute('after_login', null)
// 同步 isVip与 checkLoginStatus 一致
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname 修改头像昵称(不弹窗)
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
}
return res.data
@@ -721,8 +1018,16 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
// 同步 isVip
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}
}
return res.data.openId
}
@@ -764,9 +1069,18 @@ App({
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
this.globalData.isVip = user.isVip || false
this.globalData.vipExpireDate = user.vipExpireDate || ''
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
// 手机号登录后:若用户资料中手机号为空,从 profile 刷新并更新(后端已写入 DB可能响应中未带回
const phone = (user.phone || '').trim()
if (!phone) {
this._syncPhoneFromProfileAfterLogin(user.id)
} else {
wx.setStorageSync('user_phone', phone)
}
// 登录成功后绑定推荐码
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
@@ -775,9 +1089,14 @@ App({
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料(规则引擎接管)
checkAndExecute('after_login', null)
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}
return res.data
}
} catch (e) {

View File

@@ -1,6 +1,7 @@
{
"usingComponents": {
"icon": "/components/icon/icon"
"icon": "/components/icon/icon",
"login-modal": "/components/login-modal/login-modal"
},
"pages": [
"pages/chapters/chapters",
@@ -28,12 +29,13 @@
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list",
"pages/gift-pay/redemption-detail"
"pages/gift-pay/redemption-detail",
"pages/dev-login/dev-login"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#000000",
"navigationBarTitleText": "Soul创业派对",
"navigationBarTitleText": "卡若创业派对",
"navigationBarTextStyle": "white",
"backgroundColor": "#000000",
"navigationStyle": "custom"

View File

@@ -42,38 +42,127 @@ Component({
},
methods: {
// iconfont 映射:将业务 namelucide 风格)映射到 iconfont 的 unicode形如 "\ue6aa"
// iconfont 映射:与 static/iconfont.wxss 一一对应icon-xxx -> xxx
// 小程序不支持通过 :before { content } 渲染,因此必须直接输出 unicode 字符
getFontGlyph(name) {
const map = {
// 基础高频(来自 static/iconfont.css 的 content 值)
'wallet': '\ue6c8',
// === 来自 iconfont.wxss 完整映射 ===
'qianbao': '\ue6c8',
'gift': '\ue6c9',
'zap1': '\ue75c',
'user': '\ue6b9',
'upload': '\ue6ba',
'work': '\ue6bb',
'training': '\ue6bc',
'warning': '\ue6bd',
'zoom-in': '\ue6be',
'zoom-out': '\ue6bf',
'arrow-left-bold': '\ue6c1',
'arrow-up-bold': '\ue6c2',
'close-bold': '\ue6c3',
'arrow-down-bold': '\ue6c4',
'minus-bold': '\ue6c5',
'arrow-right-bold': '\ue6c6',
'select-bold': '\ue6c7',
'money-wallet': '\ue833',
'book-open': '\ue993',
'biaoshilei_yonghuzu': '\ue61b',
'add': '\ue664',
'add-circle': '\ue665',
'adjust': '\ue666',
'arrow-up-circle': '\ue667',
'arrow-right-circle': '\ue668',
'arrow-down': '\ue669',
'ashbin': '\ue66a',
'arrow-right': '\ue66b',
'browse': '\ue66c',
'bottom': '\ue66d',
'back': '\ue66e',
'bad': '\ue66f',
'arrow-left-circle': '\ue670',
'camera': '\ue671',
'chart-bar': '\ue672',
'attachment': '\ue673',
'code': '\ue674',
'close': '\ue675',
'check-item': '\ue676',
'calendar': '\ue677',
'comment': '\ue678',
'complete': '\ue679',
'direction-down': '\ue67a',
'direction-down-circle': '\ue67b',
'direction-right': '\ue67c',
'direction-up': '\ue67d',
'discount': '\ue67e',
'electronics': '\ue681',
'elipsis': '\ue682',
'export': '\ue683',
'explain': '\ue684',
'edit': '\ue685',
'eye-close': '\ue686',
'email': '\ue687',
'error': '\ue688',
'favorite': '\ue689',
'file-common': '\ue68a',
'file-delete': '\ue68b',
'file-add': '\ue68c',
'film': '\ue68d',
'fabulous': '\ue68e',
'file': '\ue68f',
'folder-close': '\ue690',
'filter': '\ue691',
'good': '\ue692',
'hide': '\ue693',
'home': '\ue694',
'file-open': '\ue695',
'forward': '\ue696',
'import': '\ue697',
'layers': '\ue698',
'lock': '\ue699',
'map': '\ue69a',
'menu': '\ue69b',
'help': '\ue69c',
'minus-circle': '\ue69d',
'notification': '\ue69e',
'more': '\ue69f',
'mobile-phone': '\ue6a0',
'minus': '\ue6a1',
'navigation': '\ue6a2',
'prompt': '\ue6a3',
'refresh': '\ue6a4',
'run-up': '\ue6a5',
'picture': '\ue6a6',
'run-in': '\ue6a7',
'pin': '\ue6a8',
'save': '\ue6a9',
'search': '\ue6aa',
'share': '\ue6ab',
'home': '\ue694',
'lock': '\ue699',
'camera': '\ue671',
'warning': '\ue6bd',
'scanning': '\ue6ac',
'security': '\ue6ad',
'sign-out': '\ue6ae',
'select': '\ue6af',
'stop': '\ue6b0',
'success': '\ue6b1',
'switch': '\ue6b2',
'setting': '\ue6b3',
'survey': '\ue6b4',
'time': '\ue6b5',
'telephone': '\ue6b6',
'top': '\ue6b7',
'unlock': '\ue6b8',
// 箭头/展开
// === 业务别名(兼容 lucide 等命名)===
'wallet': '\ue6c8',
'chevron-left': '\ue6c1',
'chevron-right': '\ue6c6',
'chevron-down': '\ue6c4',
'chevron-up': '\ue6c2',
'arrow-up-right': '\ue6c2',
// 交互/状态
'x': '\ue6c3',
'check': '\ue6c7',
'plus': '\ue664',
'trash-2': '\ue66a',
'pencil': '\ue685',
'zap': '\ue75c',
'info': '\ue69c',
// 语义近似映射iconfont 不一定有同名)
'map-pin': '\ue6a8',
'message-circle': '\ue678',
'smartphone': '\ue6a0',
@@ -81,9 +170,6 @@ Component({
'shield': '\ue6ad',
'star': '\ue689',
'heart': '\ue68e',
// 其他:若 iconfont 里不存在,则继续走 SVG 兜底
'book-open': '\ue993',
'bar-chart': '\ue672',
'clock': '\ue6b5',
}

View File

@@ -0,0 +1,79 @@
/**
* Soul 创业派对 - 公用登录弹窗
* 手机号一键登录 + 隐私协议
*/
Component({
properties: {
show: { type: Boolean, value: false },
desc: { type: String, value: '登录后可购买章节、解锁更多内容' },
showPrivacyModal: { type: Boolean, value: false },
showCancel: { type: Boolean, value: false }
},
data: {
agreeProtocol: false,
isLoggingIn: false
},
observers: {
show(v) {
if (!v) this.setData({ agreeProtocol: false, isLoggingIn: false })
}
},
methods: {
stopPropagation() {},
onClose() {
this.triggerEvent('close')
},
onToggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
},
onOpenUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
},
onOpenPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
},
onAgreePrivacy() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.triggerEvent('privacyagree')
},
async onPhoneLogin(e) {
if (!e.detail.code) {
return this._fallbackWechatLogin()
}
const app = getApp()
this.setData({ isLoggingIn: true })
try {
const result = await app.loginWithPhone(e.detail.code)
this.setData({ isLoggingIn: false })
if (result) {
this.triggerEvent('success')
} else {
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (err) {
this.setData({ isLoggingIn: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
},
async _fallbackWechatLogin() {
const app = getApp()
this.setData({ isLoggingIn: true })
try {
const result = await app.login()
this.setData({ isLoggingIn: false })
if (result) {
this.triggerEvent('success')
} else {
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (err) {
this.setData({ isLoggingIn: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
}
}
})

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"icon": "/components/icon/icon"
}
}

View File

@@ -0,0 +1,27 @@
<!-- Soul 创业派对 - 公用登录弹窗 -->
<view class="modal-overlay" wx:if="{{show}}" bindtap="onClose">
<view class="modal-content login-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="onClose"><icon name="x" size="36" color="#8e8e93"></icon></view>
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">{{desc}}</text>
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '手机号一键登录'}}</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy">同意</button>
</view>
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
<view class="login-agree-row" catchtap="onToggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>
<text class="agree-link" catchtap="onOpenUserProtocol">《用户协议》</text>
<text class="agree-text">和</text>
<text class="agree-link" catchtap="onOpenPrivacy">《隐私政策》</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,136 @@
/* Soul 创业派对 - 公用登录弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
}
.modal-content {
width: 100%;
max-width: 600rpx;
background: #1c1c1e;
border-radius: 32rpx;
overflow: hidden;
}
.modal-close {
position: absolute;
top: 24rpx;
right: 24rpx;
z-index: 1;
padding: 16rpx;
}
.login-modal {
padding: 48rpx 32rpx;
text-align: center;
}
.login-icon {
font-size: 80rpx;
display: block;
margin-bottom: 24rpx;
}
.login-title {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
display: block;
margin-bottom: 16rpx;
}
.login-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
display: block;
margin-bottom: 48rpx;
}
.btn-wechat {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 28rpx;
background: #07C160;
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
border-radius: 24rpx;
margin-bottom: 20rpx;
border: none;
}
.btn-wechat::after { border: none; }
.btn-wechat-icon {
width: 40rpx;
height: 40rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 8rpx;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-wechat-disabled { opacity: 0.6; }
.login-modal-cancel {
margin-top: 24rpx;
padding: 24rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
text-align: center;
}
.privacy-wechat-row {
margin: 24rpx 0;
padding: 24rpx;
background: rgba(0, 206, 209, 0.1);
border-radius: 16rpx;
}
.privacy-wechat-desc {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 16rpx;
}
.privacy-agree-btn {
width: 100%;
padding: 20rpx;
background: #07C160;
color: #fff;
font-size: 28rpx;
border-radius: 16rpx;
border: none;
}
.privacy-agree-btn::after { border: none; }
.login-agree-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-top: 32rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.agree-checkbox {
width: 32rpx;
height: 32rpx;
border: 2rpx solid rgba(255, 255, 255, 0.5);
border-radius: 6rpx;
margin-right: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
flex-shrink: 0;
}
.agree-checked {
background: #00CED1;
border-color: #00CED1;
}
.agree-text { color: rgba(255, 255, 255, 0.6); }
.agree-link {
color: #00CED1;
text-decoration: underline;
padding: 0 4rpx;
}

View File

@@ -0,0 +1,119 @@
# Soul 小程序 - 资料完善引导流程
> **入口统一**:仅 `checkVipContactRequiredAndGuide` 被 onLaunch/onShow/登录 调度;非 VIP 时内部链式调用 `checkAvatarNicknameAndGuide`。
---
## 一、整体流程(冷启动 / onShow
```mermaid
flowchart TD
Start([入口onLaunch 1.5s / onShow 0.5s]) --> CheckLogin{已登录?}
CheckLogin -->|否| End1([结束])
CheckLogin -->|是| VIPCheck[checkVipContactRequiredAndGuide]
VIPCheck --> CoolDown{3秒内已执行过?}
CoolDown -->|是| End0([结束,防重复])
CoolDown -->|否| RouteSkip{当前在 profile-edit<br/>或 avatar-nickname?}
RouteSkip -->|是| End0
RouteSkip -->|否| FetchAPI
VIPCheck --> FetchAPI[请求 APIVIP 状态 + 用户资料]
FetchAPI --> UpdateIsVip[更新 globalData.isVip]
UpdateIsVip --> IsVip{VIP?}
IsVip -->|否| AvatarCheck[checkAvatarNicknameAndGuide]
AvatarCheck --> End2([结束])
IsVip -->|是| SkipProfile{当前在 profile-edit?}
SkipProfile -->|是| End3([结束])
SkipProfile -->|否| NeedAvatar{头像/昵称未改?}
NeedAvatar -->|是| Modal1[弹窗:为了更好为您服务,请完善资料]
Modal1 --> Redirect1[redirectTo profile-edit]
Redirect1 --> End4([结束])
NeedAvatar -->|否| HasPhone{有手机号?}
HasPhone -->|否| Modal2[弹窗VIP会员需完善手机号...]
Modal2 --> Redirect2[redirectTo profile-edit]
Redirect2 --> End5([结束])
HasPhone -->|是| HasWechat{有微信号?}
HasWechat -->|是| End6([结束])
HasWechat -->|否| Modal3[弹窗:请到资料页完善微信号]
Modal3 --> Nav1[navigateTo profile-edit]
Nav1 --> End7([结束])
```
---
## 二、头像/昵称引导(非 VIP 用户)
```mermaid
flowchart TD
A([checkAvatarNicknameAndGuide]) --> B{globalData.isVip?}
B -->|是| X([跳过,由 VIP 检测处理])
B -->|否| C{头像/昵称已完善?}
C -->|是| Y([结束])
C -->|否| D{当前在 profile-edit<br/>或 avatar-nickname?}
D -->|是| Z([结束])
D -->|否| E{今日已提示过?}
E -->|是| W([结束])
E -->|否| F[弹窗:请设置头像和昵称]
F --> G{点击去完善?}
G -->|是| H[navigateTo avatar-nickname]
G -->|否 稍后| I([结束])
H --> I
```
---
## 三、首次登录(新注册用户)
```mermaid
flowchart TD
Login([登录成功]) --> NewUser{isNewUser?}
NewUser -->|否| VIPFlow[checkVipContactRequiredAndGuide]
VIPFlow --> Flow1([见流程图一])
NewUser -->|是| NeedAvatar{头像/昵称未改?}
NeedAvatar -->|否| VIPFlow
NeedAvatar -->|是| ForceRedirect[redirectTo avatar-nickname<br/>无弹窗,强制跳转]
ForceRedirect --> End([结束])
```
---
## 四、VIP 购买成功(超级个体)
```mermaid
flowchart TD
Pay([VIP 支付成功]) --> Sync[同步权益 / 拉取 VIP 状态]
Sync --> Modal[弹窗:为了更好为您服务,请填写好资料]
Modal --> Redirect[redirectTo profile-edit?from=vip]
Redirect --> End([结束])
```
---
## 五、页面职责
| 页面 | 用途 |
|------|------|
| **avatar-nickname** | 仅头像 + 昵称,专注引导新用户/非 VIP |
| **profile-edit** | 完整资料手机号、微信号、MBTI、行业等VIP 用户统一跳转 |
---
## 六、触发时机汇总
| 时机 | 执行的函数 | 延迟 |
|------|------------|------|
| onLaunch | checkVipContactRequiredAndGuide | 1500ms |
| onShow | checkVipContactRequiredAndGuide | 500ms节流 5min|
| login 成功 | checkVipContactRequiredAndGuide | 1200ms |
| getOpenId 返回 user | checkVipContactRequiredAndGuide | 1200ms |
| loginWithPhone 成功 | checkVipContactRequiredAndGuide | 1200ms |
| VIP 支付成功 | _onVipPaymentSuccess | 即时 |
---
*文档生成于资料完善引导逻辑调整后*

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 关于作者页
* 卡若创业派对 - 关于作者页
* 开发: 卡若
*/
const app = getApp()
@@ -121,13 +121,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 关于',
title: '卡若创业派对 - 关于',
path: ref ? `/pages/about/about?ref=${ref}` : '/pages/about/about'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 关于', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 关于', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -125,13 +125,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 地址管理',
title: '卡若创业派对 - 地址管理',
path: ref ? `/pages/addresses/addresses?ref=${ref}` : '/pages/addresses/addresses'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 地址管理', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 地址管理', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -203,13 +203,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 编辑地址',
title: '卡若创业派对 - 编辑地址',
path: ref ? `/pages/addresses/edit?ref=${ref}` : '/pages/addresses/edit'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 编辑地址', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 编辑地址', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 用户协议
* 卡若创业派对 - 用户协议
* 审核要求:登录前可点击《用户协议》查看完整内容
*/
const app = getApp()
@@ -23,13 +23,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 用户协议',
title: '卡若创业派对 - 用户协议',
path: ref ? `/pages/agreement/agreement?ref=${ref}` : '/pages/agreement/agreement'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 用户协议', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 用户协议', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 头像昵称引导页
* 卡若创业派对 - 头像昵称引导页
* 登录后资料未完善时引导用户修改默认头像和昵称,仅包含头像+昵称两项
*/
const app = getApp()
@@ -10,7 +10,8 @@ Page({
avatar: '',
nickname: '',
saving: false,
showAvatarModal: false,
showPrivacyModal: false,
nicknameInputFocus: false,
},
onLoad() {
@@ -40,39 +41,38 @@ Page({
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()
}
onNicknameBlur() {
this.setData({ nicknameInputFocus: false })
},
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
success: () => {
this.setData({ nicknameInputFocus: true })
},
fail: () => {},
})
},
closeAvatarModal() {
this.setData({ showAvatarModal: false })
preventMove() {},
handleAgreePrivacy() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false, nicknameInputFocus: true })
},
chooseAvatarFromAlbum() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
await this.uploadAndSaveAvatar(tempPath)
},
})
handleDisagreePrivacy() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ event: 'disagree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
await this.uploadAndSaveAvatar(tempAvatarUrl)
},
@@ -98,7 +98,10 @@ Page({
fail: reject,
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
await app.request({
url: '/api/miniprogram/user/profile',

View File

@@ -1,4 +1,4 @@
<!--Soul创业派对 - 头像昵称引导页,仅头像+昵称-->
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
@@ -15,35 +15,48 @@
<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>
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<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"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<text class="avatar-change">点击更换头像</text>
</button>
</view>
<!-- 昵称 -->
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
<view class="form-section">
<text class="form-label">昵称</text>
<view class="form-input-wrap">
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
focus="{{nicknameInputFocus}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
bindblur="onNicknameBlur"
maxlength="20"
/>
</view>
<text class="input-tip">微信用户可点击输入框自动填充昵称,或手动输入</text>
</view>
<!-- 隐私授权弹窗(昵称需授权后方可唤起微信昵称选择器) -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">为获取微信昵称,请先同意《用户隐私保护指引》</text>
<button id="agree-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacy">同意</button>
<view class="privacy-cancel" bindtap="handleDisagreePrivacy">拒绝</view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '完成'}}
</view>
@@ -54,14 +67,4 @@
</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"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
</view>

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 头像昵称引导页 */
/* 卡若创业派对 - 头像昵称引导页 */
.page {
background: #050B14;
min-height: 100vh;
@@ -72,10 +72,20 @@
.avatar-section {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 32rpx;
margin-bottom: 48rpx;
}
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
flex-shrink: 0;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap {
position: relative;
width: 192rpx;
@@ -126,7 +136,6 @@
font-size: 28rpx;
color: #5EEAD4;
font-weight: 500;
margin-top: 16rpx;
}
.form-section {
@@ -263,9 +272,79 @@
.btn-choose-avatar::after {
border: none;
}
.btn-choose-album {
width: 100%;
height: 88rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(94, 234, 212, 0.15);
color: #5EEAD4;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: 2rpx solid #5EEAD4;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}
/* 隐私授权弹窗(昵称选择器需先授权) */
.privacy-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
}
.privacy-modal {
width: 100%;
max-width: 560rpx;
background: #17212F;
border-radius: 24rpx;
padding: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.privacy-title {
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 24rpx;
}
.privacy-desc {
font-size: 28rpx;
color: #94A3B8;
text-align: center;
line-height: 1.5;
margin-bottom: 40rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
margin: 0;
}
.privacy-btn::after { border: none; }
.privacy-cancel {
margin-top: 24rpx;
font-size: 28rpx;
color: #94A3B8;
}

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 目录页
* 卡若创业派对 - 目录页
* 开发: 卡若
* 技术支持: 存客宝
* 数据: 完整真实文章标题
@@ -258,13 +258,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 目录',
title: '卡若创业派对 - 目录',
path: ref ? `/pages/chapters/chapters?ref=${ref}` : '/pages/chapters/chapters'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -0,0 +1,80 @@
/**
* 卡若创业派对 - 开发登录页
* 临时:账户=手机号,密码可空,用于切换为对方账号调试
*/
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')
Page({
data: {
statusBarHeight: 44,
account: '',
password: '',
loading: false
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44
})
},
onAccountInput(e) {
this.setData({ account: (e.detail.value || '').trim() })
},
onPasswordInput(e) {
this.setData({ password: e.detail.value || '' })
},
goBack() {
app.goBackOrToHome()
},
async handleLogin() {
const { account, password, loading } = this.data
if (!account || loading) return
const phone = account.replace(/\s/g, '')
if (phone.length < 11) {
wx.showToast({ title: '请输入11位手机号', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const res = await app.request('/api/miniprogram/dev/login-by-phone', {
method: 'POST',
data: { phone, password: password || '' }
})
if (res.success && res.data) {
const user = res.data.user
app.globalData.userInfo = user
app.globalData.isLoggedIn = true
app.globalData.purchasedSections = user.purchasedSections || []
app.globalData.hasFullBook = user.hasFullBook || false
app.globalData.isVip = user.isVip || false
app.globalData.vipExpireDate = user.vipExpireDate || ''
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
const pendingRef = wx.getStorageSync('pendingReferralCode') || app.globalData.pendingReferralCode
if (pendingRef) {
app.bindReferralCode(pendingRef)
}
checkAndExecute('after_login', null)
setTimeout(() => app.checkVipContactRequiredAndGuide(), 1200)
wx.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 800)
}
} catch (e) {
wx.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,7 @@
{
"usingComponents": {
"icon": "/components/icon/icon"
},
"navigationStyle": "custom",
"navigationBarTitleText": "账户密码登录"
}

View File

@@ -0,0 +1,51 @@
<!--开发登录页 - 临时:账户=手机号,密码可空-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.6)" customClass="back-icon"></icon>
</view>
<text class="nav-title">账户密码登录</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content">
<view class="tip-banner">
<text class="tip-text">开发专用:输入对方手机号登录,密码可留空。仅开发环境可用。</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">账户(手机号)</text>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="number"
placeholder="请输入对方手机号"
placeholder-class="input-placeholder"
value="{{account}}"
bindinput="onAccountInput"
maxlength="11"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">密码(可留空)</text>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="text"
password="{{true}}"
placeholder="可选,留空即可"
placeholder-class="input-placeholder"
value="{{password}}"
bindinput="onPasswordInput"
/>
</view>
</view>
<view class="btn-primary {{!account || loading ? 'btn-disabled' : ''}}" bindtap="handleLogin">
{{loading ? '登录中...' : '登录'}}
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,23 @@
/* 开发登录页 */
.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-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.back-icon { font-size: 40rpx; color: rgba(255,255,255,0.6); font-weight: 300; }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; }
.nav-placeholder { width: 64rpx; }
.content { padding: 24rpx 16rpx; }
.tip-banner { background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 24rpx; }
.tip-text { font-size: 24rpx; color: #FFA500; line-height: 1.5; }
.form-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; border: 2rpx solid rgba(0,206,209,0.2); }
.form-item { margin-bottom: 32rpx; }
.form-item:last-of-type { margin-bottom: 48rpx; }
.form-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; margin-bottom: 16rpx; }
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }
.input-placeholder { color: rgba(255,255,255,0.25); }
.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; }
.btn-disabled { opacity: 0.5; }

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 代付详情页
* 卡若创业派对 - 代付详情页
* 改造后:发起人支付,好友领取。支持单页模式引导、登录检测。
*/
const app = getApp()
@@ -18,7 +18,7 @@ Page({
amountDisplay: '0.00',
isSinglePageMode: false,
showLoginModal: false,
agreeProtocol: false,
showPrivacyModal: false,
// 创建态
isCreateMode: false,
giftQuantity: 1,
@@ -28,6 +28,9 @@ Page({
onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options?.ref || options?.referralCode) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
const requestSn = (options.requestSn || '').trim()
const sectionId = (options.sectionId || '').trim()
const isSinglePage = (wx.getSystemInfoSync?.()?.mode === 'singlePage') || app.globalData.isSinglePageMode
@@ -223,7 +226,7 @@ Page({
}
const userId = app.globalData.userInfo?.id
if (!userId) {
this.setData({ showLoginModal: true, agreeProtocol: false })
this.setData({ showLoginModal: true })
return
}
await this._doRedeem()
@@ -261,44 +264,15 @@ Page({
}
},
closeLoginModal() {
onLoginModalClose() {
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
onLoginModalPrivacyAgree() {
this.setData({ showPrivacyModal: false })
},
async onLoginModalSuccess() {
this.setData({ showLoginModal: false })
},
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
},
async handleWechatLogin() {
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
}
try {
const result = await app.login()
if (!result) return
this.setData({ showLoginModal: false, agreeProtocol: false })
await this._doRedeem()
} catch (e) {
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
async handlePhoneLogin(e) {
if (!e.detail.code) return this.handleWechatLogin()
try {
const result = await app.loginWithPhone(e.detail.code)
if (!result) return
this.setData({ showLoginModal: false })
await this._doRedeem()
} catch (e) {
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
stopPropagation() {},
openUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
},
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
await this._doRedeem()
},
goBack() {
@@ -323,12 +297,12 @@ Page({
const { requestSn } = this.data
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
let path = '/pages/gift-pay/detail'
if (requestSn) {
path = `/pages/gift-pay/detail?requestSn=${requestSn}`
if (ref) path += `&ref=${encodeURIComponent(ref)}`
}
const params = []
if (requestSn) params.push(`requestSn=${encodeURIComponent(requestSn)}`)
if (ref) params.push(`ref=${encodeURIComponent(ref)}`)
if (params.length) path += '?' + params.join('&')
return {
title: '好友送你一篇好文 - Soul创业派对',
title: '好友送你一篇好文 - 卡若创业派对',
path
}
},
@@ -336,14 +310,12 @@ Page({
onShareTimeline() {
const { requestSn } = this.data
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
let query = ''
if (requestSn) {
query = `requestSn=${requestSn}`
if (ref) query += `&ref=${encodeURIComponent(ref)}`
}
const params = []
if (requestSn) params.push(`requestSn=${encodeURIComponent(requestSn)}`)
if (ref) params.push(`ref=${encodeURIComponent(ref)}`)
return {
title: '好友送你一篇好文 - Soul创业派对',
query: query || ''
title: '好友送你一篇好文 - 卡若创业派对',
query: params.length ? params.join('&') : ''
}
}
})

View File

@@ -1,4 +1,4 @@
<!-- Soul创业派对 - 代付详情页(改造后:发起人支付,好友领取) -->
<!-- 卡若创业派对 - 代付详情页(改造后:发起人支付,好友领取) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
@@ -145,26 +145,15 @@
</view>
</view>
<!-- 登录弹窗(好友领取时未登录 -->
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
<view class="modal-content login-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 Soul创业派对</text>
<text class="login-desc">登录后可免费领取并阅读</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{!agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>微信快捷登录</text>
</button>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>
<text class="agree-link" bindtap="openUserProtocol">《用户协议》</text>
<text class="agree-text">和</text>
<text class="agree-link" bindtap="openPrivacy">《隐私政策》</text>
</view>
</view>
</view>
<!-- 登录弹窗(公用组件 -->
<login-modal
show="{{showLoginModal}}"
desc="登录后可免费领取并阅读"
showPrivacyModal="{{showPrivacyModal}}"
bind:close="onLoginModalClose"
bind:success="onLoginModalSuccess"
bind:privacyagree="onLoginModalPrivacyAgree"
/>
<!-- 背景光效 -->
<view class="bg-effects">

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 代付详情页(参考 yulan 深色主题、青绿主色) */
/* 卡若创业派对 - 代付详情页(参考 yulan 深色主题、青绿主色) */
.page {
min-height: 100vh;
background: #050505;
@@ -637,6 +637,10 @@
}
.btn-wechat-disabled { opacity: 0.5; }
.btn-wechat-icon { font-weight: 700; margin-right: 8rpx; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
.login-agree-row {
display: flex;
flex-wrap: wrap;

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 我发起的代付(改造后:仅我发起的,含领取记录)
* 卡若创业派对 - 我发起的代付(改造后:仅我发起的,含领取记录)
*/
const app = getApp()
@@ -10,8 +10,11 @@ Page({
loading: false
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options && (options.ref || options.referralCode)) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
this.loadData()
},
@@ -82,6 +85,8 @@ Page({
},
onShareAppMessage() {
return { title: '我的代付 - Soul创业派对', path: '/pages/gift-pay/list' }
const ref = app.getMyReferralCode?.() || app.globalData.userInfo?.referralCode || ''
const path = ref ? `/pages/gift-pay/list?ref=${encodeURIComponent(ref)}` : '/pages/gift-pay/list'
return { title: '我的代付 - 卡若创业派对', path }
}
})

View File

@@ -1,4 +1,4 @@
<!-- Soul创业派对 - 我的代付(改造后:仅我发起的,含领取记录) -->
<!-- 卡若创业派对 - 我的代付(改造后:仅我发起的,含领取记录) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 我的代付 */
/* 卡若创业派对 - 我的代付 */
.page {
min-height: 100vh;
background: #000;

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 代付领取详情(发起人查看:文章信息、领取人明细、剩余份数)
* 卡若创业派对 - 代付领取详情(发起人查看:文章信息、领取人明细、剩余份数)
*/
const app = getApp()

View File

@@ -1,4 +1,4 @@
<!-- Soul创业派对 - 代付领取详情(文章信息、领取人明细、剩余份数) -->
<!-- 卡若创业派对 - 代付领取详情(文章信息、领取人明细、剩余份数) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 代付领取详情 */
/* 卡若创业派对 - 代付领取详情 */
.page {
min-height: 100vh;
background: #050505;

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 首页
* 卡若创业派对 - 首页
* 开发: 卡若
* 技术支持: 存客宝
*/
@@ -61,6 +61,7 @@ Page({
// 链接卡若 - 留资弹窗
showLeadModal: false,
leadPhone: '',
showPrivacyModal: false,
// 展开状态(首页精选/最新)
featuredExpanded: false,
@@ -380,7 +381,7 @@ Page({
},
closeLeadModal() {
this.setData({ showLeadModal: false, leadPhone: '' })
this.setData({ showLeadModal: false, leadPhone: '', showPrivacyModal: false })
},
// 阻止弹窗内部点击事件冒泡到遮罩层
@@ -390,6 +391,15 @@ Page({
this.setData({ leadPhone: (e.detail.value || '').trim() })
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForLead() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 一键获取手机号(微信能力),成功后直接提交链接卡若
async onGetPhoneNumberForLead(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
@@ -562,13 +572,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 真实商业故事',
title: '卡若创业派对 - 真实商业故事',
path: ref ? `/pages/index/index?ref=${ref}` : '/pages/index/index'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 真实商业故事', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
<!--pages/index/index.wxml-->
<!--Soul创业派对 - 首页(按临时需求池/首页页面设计)-->
<!--卡若创业派对 - 首页(按临时需求池/首页页面设计)-->
<view class="page page-transition">
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
@@ -12,7 +12,7 @@
<text class="logo-text">S</text>
</view>
<view class="logo-info">
<text class="logo-title-text">Soul创业派对</text>
<text class="logo-title-text">卡若创业派对</text>
<text class="logo-subtitle">来自派对房的真实故事</text>
</view>
</view>
@@ -38,18 +38,19 @@
<!-- 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>
<icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon>
<text class="banner-action-text">点击阅读123</text>
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon></view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏) -->
@@ -78,7 +79,7 @@
>
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{item.name[0] || '会'}}</text>
<text class="super-avatar-text" wx:else>{{(item.name && item.name[0]) || '会'}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
@@ -87,7 +88,7 @@
<!-- 已加载无数据 -->
<view wx:else class="super-empty">
<text class="super-empty-text">成为会员,展示你的项目</text>
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 <icon name="chevron-right" size="28" color="#00CED1" customClass="inline-arrow"></icon></view>
<view class="super-empty-btn" bindtap="goToVip">加入创业派对 </view>
</view>
</view>
@@ -112,7 +113,7 @@
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag || '精选'}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
@@ -142,13 +143,7 @@
<view class="timeline-dot"></view>
<view class="timeline-content">
<view class="timeline-row">
<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>
<text class="timeline-title">{{item.title}}</text>
</view>
</view>
</view>
@@ -166,7 +161,11 @@
<view class="lead-box" catchtap="stopPropagation">
<text class="lead-title">留下联系方式</text>
<text class="lead-desc">方便卡若与您联系</text>
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
<button id="agree-lead-phone-btn" class="lead-get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumberForLead" bindagreeprivacyauthorization="onAgreePrivacyForLead">一键获取手机号</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForLead">同意</button>
</view>
<text class="lead-divider">或手动输入</text>
<view class="lead-input-wrap">
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>

View File

@@ -953,6 +953,10 @@
line-height: normal;
}
.lead-get-phone-btn::after { border: none; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
.lead-divider {
display: block;
font-size: 24rpx;

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 找伙伴页
* 卡若创业派对 - 找伙伴页
* 按H5网页端完全重构
* 开发: 卡若
*/
@@ -226,9 +226,9 @@ Page({
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
callback()
return
}
@@ -252,10 +252,10 @@ Page({
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const phone = (this.data.contactPhone || '').trim().replace(/\s/g, '')
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showToast({ title: '请输入正确的11位手机号必填', icon: 'none' })
return
}
this.setData({ contactSaving: true })
@@ -390,7 +390,7 @@ Page({
showPurchaseTip() {
wx.showModal({
title: '需要购买书籍',
content: '购买《Soul创业派对》后即可使用匹配功能仅需9.9元',
content: '购买《卡若创业派对》后即可使用匹配功能仅需9.9元',
confirmText: '去购买',
success: (res) => {
if (res.confirm) {
@@ -761,13 +761,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 找伙伴',
title: '卡若创业派对 - 找伙伴',
path: ref ? `/pages/match/match?ref=${ref}` : '/pages/match/match'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 找伙伴', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 找伙伴', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
<!--pages/match/match.wxml-->
<!--Soul创业派对 - 找伙伴页 按H5网页端完全重构-->
<!--卡若创业派对 - 找伙伴页 按H5网页端完全重构-->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
@@ -274,7 +274,7 @@
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="preventBubble">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">需完善手机号或微信号才能使用找伙伴功能</view>
<view class="contact-modal-hint">手机号必填,微信号建议填写,以便使用找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 超级个体/会员详情页
* 卡若创业派对 - 超级个体/会员详情页
* 接口:优先 /api/miniprogram/vip/members?id=xxVIP回退 /api/miniprogram/users?id=xx任意用户
* 头像/昵称统一用用户资料nickname/avatar优先随「我的」修改实时生效
* mbti, region, industry, position, businessScale, skills,
@@ -186,7 +186,7 @@ Page({
const ref = app.getMyReferralCode()
const id = this.data.member?.id
return {
title: 'Soul创业派对 - 创业者详情',
title: '卡若创业派对 - 创业者详情',
path: id && ref ? `/pages/member-detail/member-detail?id=${id}&ref=${ref}` : id ? `/pages/member-detail/member-detail?id=${id}` : ref ? `/pages/member-detail/member-detail?ref=${ref}` : '/pages/member-detail/member-detail'
}
},
@@ -195,6 +195,6 @@ Page({
const ref = app.getMyReferralCode()
const id = this.data.member?.id
const q = id ? (ref ? `id=${id}&ref=${ref}` : `id=${id}`) : (ref ? `ref=${ref}` : '')
return { title: 'Soul创业派对 - 创业者详情', query: q }
return { title: '卡若创业派对 - 创业者详情', query: q }
}
})

View File

@@ -1,4 +1,4 @@
<!-- Soul创业派对 - 超级个体详情(按 enhanced_professional_profile 1:1 还原) -->
<!-- 卡若创业派对 - 超级个体详情(按 enhanced_professional_profile 1:1 还原) -->
<view class="page">
<!-- 导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
@@ -18,7 +18,7 @@
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
@@ -132,7 +132,7 @@
<view class="bottom-wrap">
<view class="btn-super" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#00CED1" customClass="btn-arrow"></icon>
<icon name="chevron-right" size="36" color="#F59E0B" customClass="btn-arrow"></icon>
</view>
</view>
<view style="height:160rpx;"></view>

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 个人资料页enhanced_professional_profile 1:1 还原) */
/* 卡若创业派对 - 个人资料页enhanced_professional_profile 1:1 还原) */
.page { background: #050B14; min-height: 100vh; color: #fff; }
/* 导航栏 */

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 导师详情stitch_soul
* 卡若创业派对 - 导师详情stitch_soul
* 联系导师按钮 → 弹出 v2 弹窗(选择咨询项目)
*/
const app = getApp()

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 选择导师stitch_soul
* 卡若创业派对 - 选择导师stitch_soul
*/
const app = getApp()

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 我的页面
* 卡若创业派对 - 我的页面
* 开发: 卡若
* 技术支持: 存客宝
*/
@@ -60,17 +60,12 @@ Page({
// 登录弹窗
showLoginModal: false,
isLoggingIn: false,
// 用户须主动勾选同意协议(审核要求:不得默认同意)
agreeProtocol: false,
showPrivacyModal: false,
// 修改昵称弹窗
showNicknameModal: false,
editingNickname: '',
// 头像弹窗(含 chooseAvatar 按钮,必须用户点击才可获取微信头像)
showAvatarModal: false,
// 手机/微信号弹窗stitch_soul comprehensive_profile_editor_v1_2
showContactModal: false,
contactPhone: '',
@@ -412,6 +407,8 @@ Page({
wx.hideLoading()
this.setData({ receivingAll: false })
this.loadPendingConfirm()
this.loadMyEarnings()
this.loadWalletBalance()
}
},
@@ -430,9 +427,12 @@ Page({
return
}
const d = res.data
// 我的收益 = 累计佣金;我的余额 = 可提现金额(兼容 snake_case
const totalCommission = d.totalCommission ?? d.total_commission ?? 0
const availableEarnings = d.availableEarnings ?? d.available_earnings ?? 0
this.setData({
earnings: formatMoney(d.totalCommission),
pendingEarnings: formatMoney(d.availableEarnings),
earnings: formatMoney(totalCommission),
pendingEarnings: formatMoney(availableEarnings),
referralCount: d.referralCount ?? this.data.referralCount,
earningsLoading: false,
earningsRefreshing: false
@@ -458,10 +458,9 @@ Page({
wx.showToast({ title: '已刷新', icon: 'success' })
},
// 微信原生获取头像button open-type="chooseAvatar" 回调,真正获取微信头像
// 微信原生获取头像button open-type="chooseAvatar" 回调,点击头像直接唤起选择器
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
@@ -495,8 +494,11 @@ Page({
})
})
// 2. 获取上传后的完整URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
// 2. 获取上传后的完整URL(显示用);保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
console.log('[My] 头像上传成功:', avatarUrl)
// 3. 更新本地头像
@@ -506,7 +508,7 @@ Page({
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 4. 同步到服务器数据库
// 4. 同步到服务器数据库(只保存路径,不含域名)
await app.request('/api/miniprogram/user/update', {
method: 'POST',
data: { userId: userInfo.id, avatar: avatarUrl }
@@ -548,12 +550,9 @@ Page({
}
},
// 打开昵称修改弹窗
// 点击昵称跳转资料编辑页type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面)
editNickname() {
this.setData({
showNicknameModal: true,
editingNickname: this.data.userInfo?.nickname || ''
})
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 关闭昵称弹窗
@@ -689,84 +688,23 @@ Page({
console.warn('[My] 检测单页模式失败,回退为正常登录弹窗:', e)
}
try {
this.setData({ showLoginModal: true, agreeProtocol: false })
this.setData({ showLoginModal: true })
} catch (e) {
console.error('[My] showLogin error:', e)
this.setData({ showLoginModal: true })
}
},
// 切换协议勾选(用户主动勾选,非默认同意)
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
onLoginModalClose() {
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
// 打开用户协议页(审核要求:点击《用户协议》需有响应)
openUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
onLoginModalPrivacyAgree() {
this.setData({ showPrivacyModal: false })
},
// 打开隐私政策页(审核要求:点击《隐私政策》需有响应)
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
},
// 关闭登录弹窗
closeLoginModal() {
if (this.data.isLoggingIn) return
onLoginModalSuccess() {
this.initUserStatus()
this.setData({ showLoginModal: false })
},
// 微信登录(须已勾选同意协议,且做好错误处理避免审核报错)
async handleWechatLogin() {
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
}
this.setData({ isLoggingIn: true })
try {
const result = await app.login()
if (result) {
this.initUserStatus()
this.setData({ showLoginModal: false, agreeProtocol: false })
wx.showToast({ title: '登录成功', icon: 'success' })
} else {
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (e) {
console.error('[My] 微信登录错误:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
this.setData({ isLoggingIn: false })
}
},
// 手机号登录(需要用户授权)
async handlePhoneLogin(e) {
// 检查是否有授权code
if (!e.detail.code) {
// 用户拒绝授权或获取失败,尝试使用微信登录
console.log('手机号授权失败,尝试微信登录')
return this.handleWechatLogin()
}
this.setData({ isLoggingIn: true })
try {
const result = await app.loginWithPhone(e.detail.code)
if (result) {
this.initUserStatus()
this.setData({ showLoginModal: false })
wx.showToast({ title: '登录成功', icon: 'success' })
} else {
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (e) {
console.error('手机号登录错误:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
this.setData({ isLoggingIn: false })
}
wx.showToast({ title: '登录成功', icon: 'success' })
},
// 点击菜单
@@ -875,62 +813,6 @@ Page({
} catch (e) { console.log('[My] 余额查询失败', e) }
},
// 头像点击:已登录弹出选项(微信头像 / 相册)
onAvatarTap() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.showActionSheet({
itemList: ['获取微信头像', '从相册选择'],
success: (res) => {
if (res.tapIndex === 0) this.setData({ showAvatarModal: true })
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
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)
data.success ? resolve(data) : reject(new Error(data.error || '上传失败'))
} catch (e) { reject(new Error('解析失败')) }
},
fail: (e) => reject(e)
})
})
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
await app.request('/api/miniprogram/user/update', { method: 'POST', data: { userId: userInfo.id, avatar: avatarUrl } })
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败,请重试', icon: 'none' })
}
}
})
},
goToVip() {
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -974,6 +856,7 @@ Page({
wx.hideLoading()
wx.showToast({ title: '提现申请已提交', icon: 'success' })
this.loadMyEarnings()
this.loadWalletBalance()
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '提现失败', icon: 'none' })
@@ -982,18 +865,19 @@ Page({
})
},
// 提现/找伙伴前检查手机或微信号未填则弹窗stitch_soul
// 提现/找伙伴前检查联系方式:手机号必填(与 profile-edit 规则一致
async ensureContactInfo(callback) {
const userId = app.globalData.userInfo?.id
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const hasValidPhone = !!phone && /^1[3-9]\d{9}$/.test(phone)
if (hasValidPhone) {
callback()
return
}
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
this.setData({
showContactModal: true,
contactPhone: phone || '',
@@ -1015,10 +899,14 @@ Page({
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const phone = (this.data.contactPhone || '').trim().replace(/\s/g, '')
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
if (!phone) {
wx.showToast({ title: '请输入手机号(必填)', icon: 'none' })
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
wx.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
this.setData({ contactSaving: true })
@@ -1051,13 +939,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 我的',
title: '卡若创业派对 - 我的',
path: ref ? `/pages/my/my?ref=${ref}` : '/pages/my/my'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 我的', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 我的', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -16,20 +16,21 @@
<text wx:else class="guest-avatar-text">{{guestNickname[0] || '游'}}</text>
</view>
<text class="guest-name">{{guestNickname}}</text>
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
<view class="guest-login-btn" bindtap="showLogin">手机号一键登录</view>
</view>
<!-- 已登录:用户卡片(设计稿布局) -->
<view class="profile-card" wx:else>
<view class="profile-card-inner">
<view class="profile-top-row">
<view class="avatar-wrap" bindtap="onAvatarTap">
<view class="avatar-wrap">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
<view class="vip-badge vip-badge-gray" wx:else>VIP</view>
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
<view class="profile-meta">
<view class="profile-name-row">
@@ -56,7 +57,7 @@
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-val">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
@@ -87,10 +88,7 @@
</view>
<view class="receive-bottom">
<text class="receive-tip">将依次调起微信收款页完成领取</text>
<view class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">
<text>查看提现记录</text>
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.6)" customClass="receive-link-arrow"></icon>
</view>
<text class="receive-link" bindtap="handleMenuTap" data-id="withdrawRecords">查看提现记录 </text>
</view>
</view>
@@ -143,7 +141,7 @@
</view>
<view class="recent-empty" wx:else>
<text class="recent-empty-text">暂无阅读记录</text>
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 <icon name="chevron-right" size="24" color="#00CED1" customClass="recent-empty-arrow"></icon></view>
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 </view>
</view>
</view>
@@ -173,27 +171,16 @@
</view>
</view>
<!-- 登录弹窗 -->
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
<view class="modal-content login-modal-content" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 Soul创业派对</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{isLoggingIn || !agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '微信快捷登录'}}</text>
</button>
<view class="login-modal-cancel" bindtap="closeLoginModal">取消</view>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>
<text class="agree-link" catchtap="openUserProtocol">《用户协议》</text>
<text class="agree-text">和</text>
<text class="agree-link" catchtap="openPrivacy">《隐私政策》</text>
</view>
</view>
</view>
<!-- 登录弹窗(公用组件) -->
<login-modal
show="{{showLoginModal}}"
desc="登录后可购买章节、解锁更多内容"
showPrivacyModal="{{showPrivacyModal}}"
showCancel="{{true}}"
bind:close="onLoginModalClose"
bind:success="onLoginModalSuccess"
bind:privacyagree="onLoginModalPrivacyAgree"
/>
<!-- 手机/微信号弹窗 -->
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
@@ -219,17 +206,6 @@
</view>
</view>
<!-- 头像弹窗:必须点击 button 才能获取微信头像(隐私规范) -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">获取微信头像</text>
<text class="avatar-modal-desc">点击下方按钮使用你的微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
</view>
</view>
<!-- 修改昵称弹窗 -->
<view class="modal-overlay" wx:if="{{showNicknameModal}}" bindtap="closeNicknameModal">
<view class="modal-content nickname-modal" catchtap="stopPropagation">

View File

@@ -41,7 +41,22 @@
border: 1rpx solid rgba(75,85,99,0.5);
}
.profile-top-row { display: flex; align-items: flex-start; gap: 32rpx; }
.avatar-wrap { position: relative; flex-shrink: 0; }
/* 头像区域view 负责展示button 绝对定位覆盖其上,避免原生样式影响 */
.avatar-wrap {
position: relative;
width: 130rpx; height: 130rpx;
flex-shrink: 0;
}
/* 绝对定位的按钮覆盖在头像上,透明无样式,点击唤起微信选择器(微信头像/相册/拍照) */
.avatar-overlay-btn {
position: absolute;
left: 0; top: 0;
width: 130rpx; height: 130rpx;
padding: 0; margin: 0;
background: transparent; border: none;
display: block;
}
.avatar-overlay-btn::after { border: none; }
.avatar-inner {
width: 130rpx; height: 130rpx; border-radius: 50%; overflow: hidden;
background: #1C2524; border: 5rpx solid #374151;
@@ -208,6 +223,10 @@
.agree-text { color: rgba(255,255,255,0.6); }
.agree-link { color: #4FD1C5; text-decoration: underline; padding: 0 4rpx; }
.btn-wechat-disabled { opacity: 0.6; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* 头像弹窗 */
.avatar-modal .avatar-modal-title { display: block; font-size: 36rpx; font-weight: bold; color: #fff; text-align: center; margin-bottom: 16rpx; }

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 隐私政策
* 卡若创业派对 - 隐私政策
* 审核要求:登录前可点击《隐私政策》查看完整内容
*/
const app = getApp()
@@ -23,13 +23,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 隐私政策',
title: '卡若创业派对 - 隐私政策',
path: ref ? `/pages/privacy/privacy?ref=${ref}` : '/pages/privacy/privacy'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 隐私政策', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 隐私政策', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 资料编辑完整版comprehensive_profile_editor_v1_1
* 卡若创业派对 - 资料编辑完整版comprehensive_profile_editor_v1_1
* 温馨提示、头像、基本信息、核心联系方式、个人故事、互助需求、项目介绍
*
* 接口约定(/api/miniprogram/user/profile
@@ -9,6 +9,7 @@
* 表单展示:普通用户仅展示 温馨提示、头像、昵称、MBTI、地区、行业、业务体量、职位、核心联系方式VIP 展示全部
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
@@ -18,6 +19,7 @@ Page({
isVip: false,
avatar: '',
nickname: '',
shareCardPath: '', // 分享名片封面图(预生成)
mbti: '',
mbtiIndex: 0,
region: '',
@@ -37,11 +39,22 @@ Page({
showMbtiPicker: false,
saving: false,
loading: true,
showAvatarModal: false,
showPrivacyModal: false,
nicknameInputFocus: false,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
onLoad(options) {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
})
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 +98,7 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
}
@@ -95,6 +109,191 @@ Page({
goBack() { getApp().goBackOrToHome() },
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
success: () => { this.setData({ nicknameInputFocus: true }) },
fail: () => {},
})
},
onNicknameBlur() { this.setData({ nicknameInputFocus: false }) },
preventMove() {},
handleAgreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false, nicknameInputFocus: true })
},
handleDisagreePrivacy() {
if (app._privacyResolve) {
app._privacyResolve({ event: 'disagree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 生成分享名片封面图(参考:头像左+昵称右,分隔线,四栏信息 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 }) },
@@ -116,76 +315,9 @@ Page({
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
},
// 点击头像:选择微信头像从相册选择
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
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 rawUrl1 = uploadRes.data.url || ''
const avatarUrl = rawUrl1.startsWith('http://') || rawUrl1.startsWith('https://') ? rawUrl1 : app.globalData.baseUrl + rawUrl1
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' })
}
},
})
},
// 微信原生 chooseAvatar 回调:使用当前微信头像
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
this.setData({ showAvatarModal: false })
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
@@ -209,13 +341,16 @@ Page({
})
})
const rawUrl2 = uploadRes.data.url || ''
const avatarUrl = rawUrl2.startsWith('http://') || rawUrl2.startsWith('https://') ? rawUrl2 : app.globalData.baseUrl + rawUrl2
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
const avatarToSave = toAvatarPath(avatarUrl)
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
data: { userId: app.globalData.userInfo?.id, avatar: avatarToSave },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
@@ -223,6 +358,7 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
} catch (err) {
wx.hideLoading()
wx.showToast({ title: err.message || '上传失败,请重试', icon: 'none' })
@@ -235,20 +371,32 @@ Page({
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
// 手机号必填,格式校验(支持带空格/连字符输入)
const phoneRaw = s(this.data.phone)
if (!phoneRaw) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
return
}
const phoneNum = phoneRaw.replace(/\D/g, '')
if (!/^1[3-9]\d{9}$/.test(phoneNum)) {
wx.showToast({ title: '请输入正确的11位手机号', icon: 'none' })
return
}
const phoneToSave = phoneNum
this.setData({ saving: true })
try {
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
const payload = {
userId,
avatar: s(this.data.avatar),
avatar: toAvatarPath(s(this.data.avatar)),
nickname: s(this.data.nickname),
mbti: s(this.data.mbti),
region: s(this.data.region),
industry: s(this.data.industry),
businessScale: s(this.data.businessScale),
position: s(this.data.position),
phone: s(this.data.phone),
phone: phoneToSave,
wechatId: s(this.data.wechatId),
}
const showHelp = isVip || this.data.helpOffer || this.data.helpNeed
@@ -271,7 +419,7 @@ Page({
this.setData({ saving: false })
return
}
await app.request({
const res = await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: payload,
@@ -279,7 +427,7 @@ Page({
wx.showToast({ title: '保存成功', icon: 'success' })
if (app.globalData.userInfo) {
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
if (payload.avatar) app.globalData.userInfo.avatar = payload.avatar
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
setTimeout(() => getApp().goBackOrToHome(), 800)

View File

@@ -9,36 +9,39 @@
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 温馨提示 -->
<view class="tip-card">
<!-- 温馨提示from=vip 时强化权益说明 -->
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">温馨提示:需完善手机号和微信号才能使用提现和找伙伴功能</text>
<text class="tip-text">{{fromVip ? '恭喜成为VIP完善资料后即可使用找伙伴、提现等功能手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}}</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>
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<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"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
<text class="avatar-change">更换头像</text>
</button>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap">
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
class="form-input-inner"
type="nickname"
placeholder="请输入昵称"
value="{{nickname}}"
focus="{{nicknameInputFocus}}"
bindinput="onNicknameInput"
bindchange="onNicknameChange"
bindblur="onNicknameBlur"
maxlength="20"
/>
</view>
@@ -84,8 +87,8 @@
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号</text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号" value="{{phone}}" bindinput="onPhoneInput"/></view>
<text class="form-label">手机号<text class="required-mark">*</text></text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号(必填)" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
@@ -146,14 +149,16 @@
<view class="bottom-space"></view>
</scroll-view>
<!-- 头像弹窗:通过 button 获取微信头像 -->
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
<view class="modal-content avatar-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeAvatarModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<text class="avatar-modal-title">使用微信头像</text>
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
<!-- 分享名片 canvas隐藏用于生成分享图 5:4 -->
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
<!-- 隐私授权弹窗(昵称需授权后方可唤起微信昵称选择器) -->
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
<view class="privacy-modal">
<text class="privacy-title">温馨提示</text>
<text class="privacy-desc">为获取微信昵称,请先同意《用户隐私保护指引》</text>
<button id="agree-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacy">同意</button>
<view class="privacy-cancel" bindtap="handleDisagreePrivacy">拒绝</view>
</view>
</view>
</view>

View File

@@ -26,10 +26,20 @@
background: rgba(94,234,212,0.08); border: 1rpx solid rgba(94,234,212,0.25);
border-radius: 24rpx; margin-bottom: 48rpx;
}
.tip-card-highlight {
background: rgba(94,234,212,0.12); border-color: rgba(94,234,212,0.4);
}
.tip-icon { font-size: 40rpx; color: #5EEAD4; flex-shrink: 0; }
.tip-text { font-size: 26rpx; color: rgba(94,234,212,0.95); line-height: 1.6; }
.avatar-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; }
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
}
.avatar-wrap-btn::after { border: none; }
.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);
@@ -62,6 +72,7 @@
.form-row-2 { display: flex; gap: 24rpx; }
.form-row-2 .form-item { flex: 1; min-width: 0; }
.form-label { display: block; font-size: 24rpx; color: #94A3B8; margin-bottom: 12rpx; margin-left: 8rpx; }
.required-mark { color: #F87171; margin-left: 4rpx; }
/* input/textarea 用 view 包裹padding 写在 view 上 */
.form-input-wrap {
@@ -70,10 +81,37 @@
border-radius: 24rpx;
box-sizing: border-box; min-width: 0; width: 100%;
}
.form-input-suffix { position: relative; padding-right: 64rpx; }
/* 地区等带后缀图标的输入框:与 MBTI 同高,图标垂直居中 */
.form-input-suffix {
position: relative;
padding-right: 64rpx;
display: flex;
align-items: center;
min-height: 88rpx;
}
.form-input-suffix .form-input-inner {
flex: 1;
min-height: 40rpx;
line-height: 40rpx;
}
.form-input-suffix .form-suffix {
position: absolute; right: 24rpx; top: 50%; transform: translateY(-50%);
font-size: 32rpx; color: #94A3B8;
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 32rpx;
color: #94A3B8;
pointer-events: none;
}
/* form-row-2 下两列统一最小高度,与 MBTI 一致 */
.form-row-2 .form-input-wrap,
.form-row-2 .form-picker {
min-height: 88rpx;
display: flex;
align-items: center;
}
.form-row-2 .form-picker {
color: #fff;
}
.form-input-inner {
width: 100%; max-width: 100%; font-size: 28rpx; color: #fff; background: transparent;
@@ -175,9 +213,123 @@
.btn-choose-avatar::after {
border: none;
}
.btn-choose-album {
width: 100%;
height: 88rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(94, 234, 212, 0.15);
color: #5EEAD4;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
border: 2rpx solid #5EEAD4;
}
.avatar-modal-cancel {
margin-top: 24rpx;
text-align: center;
font-size: 28rpx;
color: #9CA3AF;
}
/* 昵称隐私弹窗 */
.privacy-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 560rpx;
padding: 48rpx 40rpx;
background: #1E293B;
border-radius: 24rpx;
text-align: center;
}
.privacy-modal .privacy-title { font-size: 34rpx; font-weight: 600; color: #fff; margin-bottom: 24rpx; }
.privacy-modal .privacy-desc { font-size: 28rpx; color: #94A3B8; line-height: 1.6; margin-bottom: 40rpx; }
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
/* 隐私授权弹窗 */
.privacy-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 580rpx;
background: #1E293B;
border-radius: 24rpx;
padding: 40rpx 32rpx 32rpx;
}
.privacy-modal-title {
font-size: 34rpx;
font-weight: 600;
color: #F8FAFC;
margin-bottom: 24rpx;
text-align: center;
}
.privacy-modal-desc {
font-size: 28rpx;
color: #94A3B8;
line-height: 1.5;
margin-bottom: 32rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
margin-bottom: 16rpx;
}
.privacy-btn:last-of-type {
margin-bottom: 0;
background: transparent;
color: #94A3B8;
border: 2rpx solid #475569;
}
/* 昵称隐私授权弹窗(解决 errno:104 */
.privacy-mask {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 9999;
display: flex; align-items: center; justify-content: center; padding: 48rpx;
}
.privacy-modal {
width: 100%; max-width: 600rpx; background: #1E293B; border-radius: 24rpx; padding: 48rpx;
}
.privacy-title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 16rpx; }
.privacy-desc { font-size: 28rpx; color: #94A3B8; line-height: 1.5; display: block; margin-bottom: 32rpx; }
.privacy-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #5EEAD4; color: #050B14; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
.privacy-btn::after { border: none; }
.privacy-cancel { text-align: center; margin-top: 24rpx; font-size: 28rpx; color: #94A3B8; }

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 个人资料展示页stitch_soul enhanced_professional_profile
* 卡若创业派对 - 个人资料展示页stitch_soul enhanced_professional_profile
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
*/
const app = getApp()

View File

@@ -66,13 +66,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 购买记录',
title: '卡若创业派对 - 购买记录',
path: ref ? `/pages/purchases/purchases?ref=${ref}` : '/pages/purchases/purchases'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 购买记录', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 购买记录', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 阅读页(标准流程版)
* 卡若创业派对 - 阅读页(标准流程版)
* 开发: 卡若
* 技术支持: 存客宝
*
@@ -74,7 +74,7 @@ Page({
giftPaid: false,
giftRequestSn: '',
showLoginModal: false,
agreeProtocol: false,
showPrivacyModal: false,
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
@@ -651,21 +651,21 @@ Page({
return
}
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()
if (!phone && !wechatId) {
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
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()
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone && !wechatId) {
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号或微信号,以便对方联系您',
content: '请先填写手机号(必填),以便对方联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
@@ -866,11 +866,11 @@ Page({
copyShareText() {
const { section } = this.data
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看!
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
#创业派对 #私域运营 #商业案例`
@@ -894,7 +894,7 @@ Page({
if (isGiftShare && requestSn) {
let path = `/pages/read/read?${q}&gift=1&requestSn=${encodeURIComponent(requestSn)}`
if (ref) path += `&ref=${encodeURIComponent(ref)}`
const t = section?.title || 'Soul创业派对'
const t = section?.title || '卡若创业派对'
const title = `我已为你买单:${t.length > 18 ? t.slice(0, 18) + '...' : t}`
return { title, path }
}
@@ -902,7 +902,7 @@ Page({
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创业派对 - 真实商业故事'
: '📚 卡若创业派对 - 真实商业故事'
return { title, path }
},
@@ -925,7 +925,7 @@ Page({
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#Soul创业派对 #真实商业故事`
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#卡若创业派对 #真实商业故事`
wx.setClipboardData({
data: copyText,
success: () => {
@@ -951,7 +951,7 @@ Page({
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: 'Soul创业派对 - 真实商业故事'
: '卡若创业派对 - 真实商业故事'
return { title, query }
},
@@ -974,68 +974,23 @@ Page({
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
}
try {
this.setData({ showLoginModal: true, agreeProtocol: false })
this.setData({ showLoginModal: true })
} catch (e) {
console.error('[Read] showLoginModal error:', e)
this.setData({ showLoginModal: true })
}
},
closeLoginModal() {
onLoginModalClose() {
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
onLoginModalPrivacyAgree() {
this.setData({ showPrivacyModal: false })
},
async onLoginModalSuccess() {
this.setData({ showLoginModal: false })
},
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
},
openUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
},
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
},
// 从服务端刷新购买状态,避免登录后误用旧数据导致误解锁
// 【重构】微信登录(须先勾选同意协议,符合审核要求)
async handleWechatLogin() {
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
}
try {
const result = await app.login()
if (!result) return
this.setData({ showLoginModal: false, agreeProtocol: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
},
// 【重构】手机号登录(标准流程)
async handlePhoneLogin(e) {
if (!e.detail.code) {
return this.handleWechatLogin()
}
try {
const result = await app.loginWithPhone(e.detail.code)
if (!result) return
this.setData({ showLoginModal: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 手机号登录失败:', e)
wx.showToast({ title: '登录失败', icon: 'none' })
}
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
},
// 【新增】登录成功后的标准处理流程
@@ -1537,7 +1492,7 @@ Page({
// 标题区域
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillText('📚 Soul创业派对', 20, 35)
ctx.fillText('📚 卡若创业派对', 20, 35)
// 章节标题
ctx.setFontSize(18)

View File

@@ -1,5 +1,5 @@
<!--pages/read/read.wxml-->
<!--Soul创业派对 - 阅读页-->
<!--卡若创业派对 - 阅读页-->
<view class="page">
<!-- 阅读进度条 -->
<view class="progress-bar-fixed" style="top: {{statusBarHeight}}px;">
@@ -131,7 +131,7 @@
<text class="paywall-desc">已阅读50%,登录后查看完整内容</text>
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">立即登录</text>
<text class="login-btn-text">手机号一键登录</text>
</view>
</view>
@@ -333,28 +333,15 @@
</view>
</view>
<!-- 登录弹窗 - 须勾选同意协议,《用户协议》《隐私政策》可点击查看 -->
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
<view class="modal-content login-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 Soul创业派对</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{!agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>微信快捷登录</text>
</button>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>
<text class="agree-link" catchtap="openUserProtocol">《用户协议》</text>
<text class="agree-text">和</text>
<text class="agree-link" catchtap="openPrivacy">《隐私政策》</text>
</view>
</view>
</view>
<!-- 登录弹窗(公用组件) -->
<login-modal
show="{{showLoginModal}}"
desc="登录后可购买章节、解锁更多内容"
showPrivacyModal="{{showPrivacyModal}}"
bind:close="onLoginModalClose"
bind:success="onLoginModalSuccess"
bind:privacyagree="onLoginModalPrivacyAgree"
/>
<!-- 支付中提示 -->
<view class="modal-overlay" wx:if="{{isPaying}}" catchtap="">

View File

@@ -1164,6 +1164,10 @@
padding: 0 4rpx;
}
.btn-wechat-disabled { opacity: 0.6; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* ===== 支付中加载 ===== */
.loading-box {

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 分销中心页
* 卡若创业派对 - 分销中心页
*
* 可见数据:
* - 绑定用户数(当前有效绑定)
@@ -590,25 +590,25 @@ Page({
shareToMoments() {
// 10条随机文案基于书的内容
const shareTexts = [
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n62个真实案例搜"Soul创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n62个真实案例搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`💡 今天终于明白:会赚钱的人,都在用"流量杠杆"\n\n抖音、Soul、飞书...同一套内容,撬动不同平台的流量。\n\nSoul创业派对》里的实战方法,受用终身!\n\n#流量 #副业 #创业派对`,
`💡 今天终于明白:会赚钱的人,都在用"流量杠杆"\n\n抖音、Soul、飞书...同一套内容,撬动不同平台的流量。\n\n卡若创业派对》里的实战方法,受用终身!\n\n#流量 #副业 #创业派对`,
`📚 一个70后大健康私域一个月150万流水是怎么做到的\n\n答案在《Soul创业派对》第9章全是干货。\n\n搜小程序"Soul创业派对",我在里面等你\n\n#大健康 #私域运营 #真实案例`,
`📚 一个70后大健康私域一个月150万流水是怎么做到的\n\n答案在《卡若创业派对》第9章全是干货。\n\n搜小程序"卡若创业派对",我在里面等你\n\n#大健康 #私域运营 #真实案例`,
`🎯 "分钱不是分你的钱,是分不属于对方的钱"\n\n这句话改变了我对商业合作的认知。\n\n推荐《Soul创业派对》,创业者必读!\n\n#云阿米巴 #商业思维 #创业`,
`🎯 "分钱不是分你的钱,是分不属于对方的钱"\n\n这句话改变了我对商业合作的认知。\n\n推荐《卡若创业派对》,创业者必读!\n\n#云阿米巴 #商业思维 #创业`,
`✨ 资源整合高手的社交方法论,在派对房里学到了\n\n"先让对方赚到钱,自己才能长久赚钱"\n\n这本《Soul创业派对》,每章都是实战经验\n\n#资源整合 #社交 #创业故事`,
`✨ 资源整合高手的社交方法论,在派对房里学到了\n\n"先让对方赚到钱,自己才能长久赚钱"\n\n这本《卡若创业派对》,每章都是实战经验\n\n#资源整合 #社交 #创业故事`,
`🚀 AI工具推广一个隐藏的高利润赛道\n\n客单价高、复购率高、需求旺盛...\n\nSoul创业派对》里的商业机会,你发现了吗?\n\n#AI #副业 #商业机会`,
`🚀 AI工具推广一个隐藏的高利润赛道\n\n客单价高、复购率高、需求旺盛...\n\n卡若创业派对》里的商业机会,你发现了吗?\n\n#AI #副业 #商业机会`,
`💰 美业整合:一个人的公司如何月入十万?\n\n不开店、不囤货、轻资产运营...\n\nSoul创业派对》告诉你答案!\n\n#美业 #轻创业 #月入十万`,
`💰 美业整合:一个人的公司如何月入十万?\n\n不开店、不囤货、轻资产运营...\n\n卡若创业派对》告诉你答案!\n\n#美业 #轻创业 #月入十万`,
`🌟 3000万流水是怎么跑出来的\n\n不是靠运气,是靠系统。\n\nSoul创业派对》里的电商底层逻辑,值得反复看\n\n#电商 #创业 #商业系统`,
`🌟 3000万流水是怎么跑出来的\n\n不是靠运气,是靠系统。\n\n卡若创业派对》里的电商底层逻辑,值得反复看\n\n#电商 #创业 #商业系统`,
`📖 "人与人之间的关系,归根结底就三个东西:利益、情感、价值观"\n\n在派对房里聊出的金句,都在《Soul创业派对》里\n\n#人性 #商业 #创业派对`,
`📖 "人与人之间的关系,归根结底就三个东西:利益、情感、价值观"\n\n在派对房里聊出的金句,都在《卡若创业派对》里\n\n#人性 #商业 #创业派对`,
`🔔 未来职业的三个方向:技术型、资源型、服务型\n\n你属于哪一种?\n\nSoul创业派对》帮你找到答案!\n\n#职业规划 #创业 #未来`
`🔔 未来职业的三个方向:技术型、资源型、服务型\n\n你属于哪一种?\n\n卡若创业派对》帮你找到答案!\n\n#职业规划 #创业 #未来`
]
// 随机选择一条文案
@@ -892,7 +892,7 @@ Page({
const ref = this.data.referralCode || app.getMyReferralCode()
console.log('[Referral] 分享给好友,推荐码:', ref)
return {
title: 'Soul创业派对 - 来自派对房的真实商业故事',
title: '卡若创业派对 - 来自派对房的真实商业故事',
path: ref ? `/pages/index/index?ref=${ref}` : '/pages/index/index'
// 不设置 imageUrl使用小程序默认截图
// 如需自定义图片,请将图片放在 /assets/ 目录并配置路径
@@ -904,7 +904,7 @@ Page({
const ref = this.data.referralCode || app.getMyReferralCode()
console.log('[Referral] 分享到朋友圈,推荐码:', ref)
return {
title: `Soul创业派对 - 62个真实商业案例`,
title: `卡若创业派对 - 62个真实商业案例`,
query: ref ? `ref=${ref}` : ''
// 不设置 imageUrl使用小程序默认截图
}

View File

@@ -134,7 +134,7 @@
<view class="user-avatar {{item.status === 'converted' ? 'avatar-converted' : item.status === 'expired' ? 'avatar-expired' : ''}}">
<icon wx:if="{{item.status === 'converted'}}" name="check" size="28" color="#34C759"></icon>
<icon wx:elif="{{item.status === 'expired'}}" name="clock" size="28" color="#ff9500"></icon>
<text wx:else>{{item.nickname[0] || '用'}}</text>
<text wx:else>{{(item.nickname && item.nickname[0]) || '用'}}</text>
</view>
<view class="user-info">
<text class="user-name">{{item.nickname || '匿名用户'}}</text>

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 章节搜索页
* 卡若创业派对 - 章节搜索页
* 搜索章节标题和内容
*/
const app = getApp()
@@ -129,13 +129,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 搜索',
title: '卡若创业派对 - 搜索',
path: ref ? `/pages/search/search?ref=${ref}` : '/pages/search/search'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 搜索', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 搜索', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,8 +1,9 @@
/**
* Soul创业派对 - 设置页
* 卡若创业派对 - 设置页
* 账号绑定功能
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
Page({
data: {
@@ -29,7 +30,8 @@ Page({
// 绑定弹窗
showBindModal: false,
bindType: '', // phone | wechat | alipay
bindValue: ''
bindValue: '',
showPrivacyModal: false
},
onLoad() {
@@ -245,70 +247,101 @@ Page({
}
},
// 微信原生 chooseAvatar 回调
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
// 获取微信头像(新版授权)
async getWechatAvatar() {
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: { folder: 'avatars' },
success: (uploadResult) => {
try {
const data = JSON.parse(uploadResult.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch (err) {
reject(new Error('解析响应失败'))
const res = await wx.getUserProfile({
desc: '用于完善会员资料'
})
if (res.userInfo) {
const { nickName, avatarUrl: tempAvatarUrl } = res.userInfo
wx.showLoading({ title: '上传中...', mask: true })
// 1. 先上传图片到服务器
console.log('[Settings] 开始上传头像:', tempAvatarUrl)
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: {
folder: 'avatars'
},
success: (uploadResult) => {
try {
const data = JSON.parse(uploadResult.data)
if (data.success) {
resolve(data)
} else {
reject(new Error(data.error || '上传失败'))
}
} catch (err) {
reject(new Error('解析响应失败'))
}
},
fail: (err) => {
reject(err)
}
},
fail: reject
})
})
})
const rawUrl = uploadRes.data.url || ''
const avatarUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://')
? rawUrl
: app.globalData.baseUrl + rawUrl
const nickname = this.data.userInfo?.nickname || app.globalData.userInfo?.nickname || ''
this.setData({
userInfo: {
...this.data.userInfo,
nickname,
avatar: avatarUrl
// 2. 获取上传后的完整URL显示用保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
})
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/miniprogram/user/profile', {
method: 'POST',
data: { userId, nickname, avatar: avatarUrl }
console.log('[Settings] 头像上传成功:', avatarUrl)
// 3. 更新本地
this.setData({
userInfo: {
...this.data.userInfo,
nickname: nickName,
avatar: avatarUrl
}
})
// 4. 同步到服务器数据库(只保存路径,不含域名)
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/miniprogram/user/profile', {
method: 'POST',
data: { userId, nickname: nickName, avatar: toAvatarPath(avatarUrl) }
})
}
// 5. 更新全局
if (app.globalData.userInfo) {
app.globalData.userInfo.nickname = nickName
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像更新成功', icon: 'success' })
}
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()
console.error('[Settings] 更新头像失败:', e)
wx.showToast({
title: e.message || '上传失败,请重试',
icon: 'none'
console.error('[Settings] 获取头像失败:', e)
wx.showToast({
title: e.message || '获取头像失败',
icon: 'none'
})
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForPhone() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 一键获取微信手机号button组件回调
async onGetPhoneNumber(e) {
console.log('[Settings] 获取手机号回调:', e.detail)
@@ -369,6 +402,11 @@ Page({
this.setData({ showBindModal: false })
},
// 跳转账户密码登录页(开发)
goToDevLogin() {
wx.navigateTo({ url: '/pages/dev-login/dev-login' })
},
// 打开切换账号弹窗(开发)
openSwitchAccountModal() {
this.setData({
@@ -488,13 +526,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 设置',
title: '卡若创业派对 - 设置',
path: ref ? `/pages/settings/settings?ref=${ref}` : '/pages/settings/settings'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 设置', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 设置', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -32,11 +32,15 @@
</view>
<view class="bind-right">
<icon wx:if="{{phoneNumber}}" name="check" size="36" color="#34C759" customClass="bind-check"></icon>
<button wx:else class="get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
<button wx:else id="agree-settings-phone-btn" class="get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumber" bindagreeprivacyauthorization="onAgreePrivacyForPhone">
一键获取
</button>
</view>
</view>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForPhone">同意</button>
</view>
<!-- 微信号 - 简化输入 -->
<view class="bind-item">
@@ -117,6 +121,13 @@
<text class="dev-switch-desc">输入 userId 切换为其他账号调试</text>
</view>
</view>
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="goToDevLogin">
<view class="dev-switch-inner">
<icon name="smartphone" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">账户密码登录</text>
<text class="dev-switch-desc">输入对方手机号登录,密码可留空</text>
</view>
</view>
<view class="logout-btn" wx:if="{{isLoggedIn}}" bindtap="handleLogout">退出登录</view>
</view>

View File

@@ -49,6 +49,10 @@
line-height: normal;
}
.get-phone-btn::after { border: none; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* 自动提现卡片 */
.auto-withdraw-card { margin-top: 24rpx; }

View File

@@ -85,20 +85,7 @@ Page({
return
}
}
// 支付前:若头像/昵称仍为默认值,引导完善(仅头像+昵称)
if (this._shouldGuideAvatarNickname()) {
wx.showModal({
title: '完善资料',
content: '开通超级个体前,请先设置头像和昵称,让他人更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
})
return
}
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
this.setData({ purchasing: true })
const amount = this.data.price
try {
@@ -173,23 +160,21 @@ Page({
else if (typeof p.updateUserStatus === 'function') p.updateUserStatus()
})
// 开通成功后兜底:仍为默认头像/昵称则引导完善
if (this._shouldGuideAvatarNickname()) {
wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
// 超级个体购买后:弹窗提示,强制跳转资料编辑页
wx.hideLoading()
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请填写好资料',
confirmText: '去完善',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?from=vip' })
}
})
} catch (e) {
console.error('[VIP] 支付后同步失败:', e)
wx.hideLoading()
}
wx.hideLoading()
},
_shouldGuideAvatarNickname() {
const user = app.globalData.userInfo || {}
const avatar = (user.avatar || user.avatarUrl || '').trim()
const nickname = (user.nickname || user.nickName || '').trim()
// 与 ruleEngine.checkRule_FillAvatar 保持同口径(允许前端兜底)
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return false
return true
},
goBack() { getApp().goBackOrToHome() },
@@ -197,13 +182,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - VIP会员',
title: '卡若创业派对 - VIP会员',
path: ref ? `/pages/vip/vip?ref=${ref}` : '/pages/vip/vip'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - VIP会员', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - VIP会员', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,4 +1,4 @@
<!-- Soul创业派对 - 我的余额 -->
<!-- 卡若创业派对 - 我的余额 -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">

View File

@@ -1,4 +1,4 @@
/* Soul创业派对 - 我的余额 - 深色主题 */
/* 卡若创业派对 - 我的余额 - 深色主题 */
.page {
min-height: 100vh;
background: #0a0a0a;

View File

@@ -125,13 +125,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: 'Soul创业派对 - 提现记录',
title: '卡若创业派对 - 提现记录',
path: ref ? `/pages/withdraw-records/withdraw-records?ref=${ref}` : '/pages/withdraw-records/withdraw-records'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: 'Soul创业派对 - 提现记录', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 提现记录', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -1,7 +1,7 @@
{
"compileType": "miniprogram",
"miniprogramRoot": "",
"description": "Soul创业派对 - 来自派对房的真实商业故事",
"description": "卡若创业派对 - 来自派对房的真实商业故事",
"appid": "wxb8bbb2b10dec74aa",
"setting": {
"urlCheck": false,

View File

@@ -24,12 +24,40 @@
"miniprogram": {
"list": [
{
"name": "pages/gift-pay/list",
"pathName": "pages/gift-pay/list",
"name": "开发登录",
"pathName": "pages/dev-login/dev-login",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/member-detail/member-detail",
"pathName": "pages/member-detail/member-detail",
"query": "id=ogpTW5cVMxd5afBBtXdvmeMO8aho",
"launchMode": "default",
"scene": null
},
{
"name": "pages/my/my",
"pathName": "pages/my/my",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "个人资料",
"pathName": "pages/avatar-nickname/avatar-nickname",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "pages/gift-pay/list",
"pathName": "pages/gift-pay/list",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "代付",
"pathName": "pages/gift-pay/detail",

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 内容解析工具
* 卡若创业派对 - 内容解析工具
* 解析 TipTap HTML 为阅读页可展示的 segments
*
* segment 类型:

View File

@@ -1,6 +1,7 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
@@ -15,7 +16,14 @@
* 浏览导师页 → browse_mentor
*/
const app = getApp()
function getAppInstance() {
try {
const a = getApp()
return a && a.globalData ? a : null
} catch (e) {
return null
}
}
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
@@ -59,11 +67,14 @@ function setCooldown(ruleId) {
}
function getUserInfo() {
return app.globalData.userInfo || {}
const app = getAppInstance()
return app ? (app.globalData.userInfo || {}) : {}
}
async function loadRules() {
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
const app = getAppInstance()
if (!app) return _cachedRules || []
try {
const res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
if (res && res.success && res.rules) {
@@ -85,8 +96,12 @@ function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
}
// 稳定版:跳转 avatar-nickname专注头像+昵称,首次登录由 app.login 强制 redirect
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit避免与主流程冲突
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) return null
const app = getAppInstance()
if (app && app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
const avatar = user.avatar || user.avatarUrl || ''
@@ -100,7 +115,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 +153,13 @@ 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 app = getAppInstance()
const readCount = app ? (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 0)) : 0
if (readCount < 5) return null
if (isInCooldown('share_after_5')) return null
setCooldown('share_after_5')
@@ -156,11 +173,13 @@ 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
const app = getAppInstance()
if (!app || !(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')
@@ -212,7 +231,8 @@ function checkRule_Withdraw(rules) {
if (!isRuleEnabled(rules, '收益满50元')) return null
const user = getUserInfo()
if (!user.id) return null
const earnings = app.globalData.totalEarnings || 0
const app = getAppInstance()
const earnings = app ? (app.globalData.totalEarnings || 0) : 0
if (earnings < 50) return null
if (isInCooldown('withdraw_50')) return null
setCooldown('withdraw_50')
@@ -274,6 +294,8 @@ function executeRule(rule, pageInstance) {
function _trackRuleAction(ruleId, action) {
const userId = getUserInfo().id
if (!userId) return
const app = getAppInstance()
if (!app) return
app.request({
url: '/api/miniprogram/track',
method: 'POST',

View File

@@ -1,5 +1,5 @@
/**
* Soul创业派对 - 小程序码 scene 参数统一编解码(海报生成 ↔ 扫码解析闭环)
* 卡若创业派对 - 小程序码 scene 参数统一编解码(海报生成 ↔ 扫码解析闭环)
* 官方以 options.scene 接收扫码参数;后端生成码时会把 & 转为 _故解析时同时支持 & 和 _
* scene 同时可带两个参数:章节标识(mid/id) + 推荐人(ref)
*/

View File

@@ -171,6 +171,20 @@ const showConfirm = (title, content) => {
})
}
/**
* 从头像 URL 提取路径部分(不含域名),用于保存到后端
* 例如https://xxx.com/uploads/avatars/1.jpg → /uploads/avatars/1.jpg
* @param {string} url - 完整 URL 或路径
* @returns {string}
*/
const toAvatarPath = url => {
if (!url || typeof url !== 'string') return url || ''
const idx = url.indexOf('/uploads/')
if (idx >= 0) return url.substring(idx)
if (url.startsWith('/')) return url
return url
}
module.exports = {
formatTime,
formatDate,
@@ -188,5 +202,6 @@ module.exports = {
showToast,
showLoading,
hideLoading,
showConfirm
showConfirm,
toAvatarPath
}

View File

@@ -0,0 +1,94 @@
# Soul 小程序 - 模拟测试清单
在**微信开发者工具**中打开 `miniprogram` 项目,按以下步骤逐项验证。
---
## 一、前置准备
1. 启动微信开发者工具,打开项目 `e:\Gongsi\Mycontent\miniprogram`
2. 确认后端 soul-api 已启动baseUrl 指向正确,如 `http://localhost:8080`
3. 准备测试账号:① 普通用户(未完善) ② VIP 用户(无手机号) ③ 已完善用户
---
## 二、VIP 无手机号 / 头像未改(先判断 VIP
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 2.1 | 用 **VIP 且无手机号** 的账号登录 | 登录成功 |
| 2.2 | 完全关闭小程序,重新编译并启动 | 约 1.5s 后弹窗「VIP会员需完善手机号...」→ 点击「去完善」→ redirectTo profile-edit |
| 2.3 | 用 **VIP 有手机号但头像/昵称未改**(旧数据) | 弹窗「为了更好为您服务,请完善资料」→ redirectTo profile-edit |
| 2.4 | 在资料页填写并保存手机号 | 保存成功,可返回首页 |
| 2.5 | 再次关闭并重新启动 | 不再弹窗或跳转,正常进入首页 |
---
## 三、头像/昵称引导(非 VIP 老用户)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 3.1 | 用 **非 VIP、头像/昵称仍为默认** 的账号冷启动 | 先获取 VIP 状态 → 非 VIP → 弹窗「请设置头像和昵称」,每日最多 1 次 |
| 3.2 | 点击「去完善」 | navigateTo avatar-nickname |
| 3.3 | 点击「稍后」 | 弹窗关闭,可正常使用 |
| 3.4 | **新注册用户**isNewUser且头像未改 | 无弹窗,直接 redirectTo avatar-nickname |
| 3.5 | 当前已在 profile-edit 或 avatar-nickname 页 | 不再重复弹窗或跳转 |
---
## 四、检测顺序VIP 优先)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 4.1 | 用 **VIP 无手机号 + 头像默认** 账号登录 | 先执行 VIP 检测 → 直接 redirectTo profile-edit |
| 4.2 | 验证不会先到头像页 | VIP 优先,直接进 profile-edit不会先到 avatar-nickname |
---
## 五、VIP 购买流程
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 5.1 | 进入 VIP 页点击「开通」并完成购买 | 购买前不拦截;购买成功后弹窗说明,确认后跳转 profile-edit?from=vip |
| 5.2 | VIP 购买成功后点击「去完善」 | 跳转到 profile-edit含手机号必填 |
| 5.3 | 在 profile-edit 不填手机号直接保存VIP 账号) | 提示「请输入手机号VIP会员必填」 |
| 5.4 | 填写正确手机号后保存 | 保存成功 |
---
## 六、微信号引导(非强制)
| 步骤 | 操作 | 预期结果 |
|------|------|----------|
| 6.1 | 用 **VIP 有手机号但无微信号** 账号登录 | 弹窗「请到资料页完善微信号」 |
| 6.2 | 点击「去完善」或「稍后」 | 去完善→跳转;稍后→关闭 |
---
## 七、快捷自检命令(开发者工具控制台)
在**调试器 → Console** 中可粘贴以下代码快速模拟状态:
```javascript
// 查看当前 userInfo
getApp().globalData.userInfo
// 手动触发检测(需已登录)
getApp().checkVipContactRequiredAndGuide()
getApp().checkAvatarNicknameAndGuide()
```
---
## 八、常见问题排查
| 现象 | 可能原因 |
|------|----------|
| 未触发 VIP redirect | 1) 非 VIP 2) 已填手机号 3) 当前已在 profile-edit 页 |
| 头像未改却未强制跳转 | 检查当前页是否为 profile-edit 或 avatar-nickname会跳过 |
| 后端请求失败 | 确认 soul-api 已启动baseUrl 正确 |
| 登录态丢失 | 检查 token、userInfo 是否持久化 |
---
测试完成后,可在本文件末尾记录:✅ 通过 / ❌ 失败及原因。

View File

@@ -0,0 +1,166 @@
import json
import re
from dataclasses import dataclass
from typing import Any
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
@dataclass
class Route:
group: str # "admin" | "db" | "root"
method: str
path: str # path within the group, e.g. "/chapters" or "/admin"
full_path: str # full path appended to API_BASE_URL
def _read_text(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_admin_and_db_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 已包含 /api/admin 或 /api/db 前缀,保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# 1) /api/admin 登录/鉴权/登出(不是 admin group 内)
# api.GET("/admin", ...) / api.POST("/admin", ...) / api.POST("/admin/logout", ...)
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# 2) admin groupapi.Group("/admin") + admin.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# 3) db groupapi.Group("/db") + db.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重(同一 handler 可能存在重复注册)
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for method, p in routes:
k = (method, p)
if k in seen:
continue
seen.add(k)
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
# 仅用于 smoke把 :id 替换成一个固定占位
return path.replace(":id", "1")
def request_json(
session: requests.Session,
method: str,
url: str,
headers: dict[str, str],
payload: Any | None = None,
raw_body: str | None = None,
) -> tuple[int, dict[str, Any] | None, str]:
try:
if raw_body is not None:
resp = session.request(method, url, headers=headers, data=raw_body, timeout=10)
elif payload is None:
resp = session.request(method, url, headers=headers, timeout=10)
else:
resp = session.request(method, url, headers=headers, data=json.dumps(payload), timeout=10)
text = resp.text or ""
try:
data = resp.json()
except Exception:
data = None
return resp.status_code, data, text[:300]
except Exception as e:
return 0, None, f"EXC: {e}"
def main() -> None:
api_base = None
# 优先使用本地默认;需要对接测试环境时在 PowerShell 设置 SOUL_API_BASE
import os
api_base = (os.environ.get("SOUL_API_BASE") or "").rstrip("/")
if not api_base:
# 默认本机
api_base = "http://localhost:8080"
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
session = requests.Session()
# 本 smoke 默认不验证 TLS如果你用的是 https 且是自签证书,能跑通测试)
session.verify = False
# 登录拿 token
login_url = f"{api_base}/api/admin"
r = session.post(login_url, json={"username": admin_username, "password": admin_password}, timeout=10)
try:
login_data = r.json()
except Exception:
login_data = None
if r.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
print("LOGIN_FAILED", r.status_code, r.text[:200])
return
token = login_data["token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
routes = extract_admin_and_db_routes()
print(f"Found routes: {len(routes)}")
failures: list[dict[str, Any]] = []
unexpected_success: list[dict[str, Any]] = []
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
payload = None
raw_body = None
if method in ("POST", "PUT", "DELETE"):
# 安全模式:发送明显非法 JSON尽量触发 ShouldBindJSON 失败,避免真实写入。
payload = None
raw_body = "{invalid_json"
status, data, preview = request_json(
session, method, url, headers, payload=payload, raw_body=raw_body
)
ok = status not in (404, 500) and status != 0
# POST/PUT/DELETE 在安全模式下不应返回 success=true
if method in ("POST", "PUT", "DELETE") and data and data.get("success") is True:
unexpected_success.append(
{"method": method, "path": path, "status": status, "data": data, "preview": preview}
)
if not ok:
failures.append({"method": method, "path": path, "status": status, "data": data, "preview": preview})
print("\n=== SMOKE_RESULT ===")
print("Failures(404/500/EXC):", len(failures))
if failures:
for it in failures:
print(f"- {it['method']} {it['path']} -> {it['status']}, preview={it.get('preview')}")
print("\nUnexpected success on write calls:", len(unexpected_success))
if unexpected_success:
for it in unexpected_success:
print(f"- {it['method']} {it['path']} -> success=true (status {it['status']}, preview={it.get('preview')})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,132 @@
import re
from dataclasses import dataclass
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
@dataclass
class Check:
method: str
path: str
status: int
preview: str
def _read_text(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# /api/admin 登录/鉴权/登出
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# /api/admin 组
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# /api/db 组
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重
seen = set()
out = []
for method, p in routes:
if (method, p) in seen:
continue
seen.add((method, p))
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
return path.replace(":id", "1")
def main() -> None:
import os
api_base = (os.environ.get("SOUL_API_BASE") or "http://localhost:8080").rstrip("/")
session = requests.Session()
session.verify = False # 如为 https 自签证书也可探测
routes = extract_routes()
print(f"Found routes: {len(routes)}")
failures: list[Check] = []
unexpected: list[Check] = []
headers = {"Content-Type": "application/json"}
# 先验证登录接口是否通(只对 /api/admin POST 登录做一次带凭证的检查)
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
login_url = f"{api_base}/api/admin"
r_login = session.post(
login_url,
json={"username": admin_username, "password": admin_password},
headers=headers,
timeout=10,
)
try:
login_data = r_login.json()
except Exception:
login_data = None
if r_login.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
failures.append(Check("POST", "/api/admin", r_login.status_code, (r_login.text or "")[:200]))
print("LOGIN_CHECK_FAILED后续路由鉴权探测可能不准确。")
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
# 仅对登录接口放行;其他都不带 token避免触发写操作
json_payload = None
if path == "/api/admin" and method == "POST":
# 已在上面验证登录;这里跳过
continue
if method in ("POST", "PUT"):
# 发空 body通常也会被 AdminAuth 在更早阶段拦截
json_payload = {}
try:
resp = session.request(method, url, headers=headers, json=json_payload, timeout=10)
status = resp.status_code
preview = (resp.text or "")[:200].replace("\n", " ")
except Exception as e:
failures.append(Check(method, path, 0, f"EXC: {e}"))
continue
# 非登录接口:预期 AdminAuth 拦截 => 401 或 403
if status not in (401, 403):
unexpected.append(Check(method, path, status, preview))
print("\n=== AUTHLESS_SMOKE_RESULT ===")
print("Failures(0/404/500 等异常/网络异常):", len(failures))
for it in failures[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
print("Unexpected (非 401/403):", len(unexpected))
for it in unexpected[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
if len(unexpected) > 30:
print("... truncated")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

914
soul-admin/dist/assets/index-DCoaVA6V.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-34teBEu9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7tt33mg.css">
<script type="module" crossorigin src="/assets/index-DCoaVA6V.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGXqHqcA.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { get, post } from '@/api/client'
@@ -33,6 +33,13 @@ interface Stats {
totalParts: number
}
/** 篇内章区间(按章条数,非节数) */
function chapterRangeLabel(chapterCount: number): string {
if (chapterCount <= 0) return '暂无章节'
if (chapterCount === 1) return '第1章'
return `第1章 ~ 第${chapterCount}`
}
export function ChaptersPage() {
const [structure, setStructure] = useState<Part[]>([])
const [stats, setStats] = useState<Stats | null>(null)
@@ -204,7 +211,8 @@ export function ChaptersPage() {
</span>
<span className="font-semibold text-white">{part.title}</span>
<span className="text-white/40 text-sm">
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
({chapterRangeLabel(part.chapters.length)} ·{' '}
{part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
</span>
</div>
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>

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