Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/app.js   resolved by devlop version
#	miniprogram/pages/chapters/chapters.js   resolved by devlop version
#	miniprogram/pages/match/match.js   resolved by devlop version
#	miniprogram/pages/member-detail/member-detail.js   resolved by devlop version
#	miniprogram/pages/my/my.js   resolved by devlop version
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/referral/referral.js   resolved by devlop version
#	soul-api/internal/model/person.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

View File

@@ -0,0 +1,13 @@
# 2026-03-21 MBTI 头像 C 端全链路兜底
## 问题
系统设置瘦身与 MBTI 映射迁到用户管理后,需在小程序多页面与匹配接口统一「无微信头像 → MBTI 映射」行为,避免仅海报单点生效。
## 做法
- 新增 `miniprogram/utils/mbtiAvatar.js``resolveAvatarWithMbti``app.resolveAvatarWithMbti` 封装全局 map。
- 我的页 `profileAvatarDisplay`;资料编辑 `avatarPreviewUrl`profile-show、member-detail、referral 海报复用同一逻辑。
- 后端 `match.go``avatar` 为空时用 `getMbtiAvatar`;响应增加 `mbti` 字段;找伙伴卡片 wxml 增加无图占位。
- 管理端 `MbtiAvatarsManager` 补充 downloadFile 域名说明。
## 可复用规则
配置驱动展示:公开 `GET /api/miniprogram/config/mbti-avatars` + 本地短时缓存;业务侧只调 `resolveAvatarWithMbti`,避免重复拼接 baseUrl。

View File

@@ -4,4 +4,5 @@
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md |
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts | 2026-03-16-交互习惯分析.md |

View File

@@ -0,0 +1,38 @@
# 飞书「开发群」与 Soul 项目复盘约定
## 绑定关系
- **Soul 创业派对(永平)**、**派对 AI 相关自动化**、**卡若 AI 侧发往本项目的复盘**,默认使用**同一开发群机器人 Webhook**。
- Webhook 与项目在配置上是**一一绑定**:换群 = 改环境变量或下方脚本中的默认 URL并确保飞书里该群已添加对应自定义机器人。
## 默认 Webhook开发群
环境变量(推荐在本机 shell 或 `scripts/.env.feishu` 同目录的 `.env` 中导出):
| 变量名 | 用途 |
|--------|------|
| `FEISHU_DEV_GROUP_WEBHOOK` | **主约定**:开发群统一入口;未设置时各脚本使用内置默认值。 |
当前默认 URL与飞书群内机器人一致
`https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494`
## 已接此 Webhook 的脚本(代码内默认或可读此变量)
| 脚本 | 说明 |
|------|------|
| `scripts/send_chapter_poster_to_feishu.py` | 章节摘要 + 海报图(小程序码) |
| 卡若AI `飞书管理/脚本/send_review_to_feishu_webhook.py` | 卡若 AI 复盘(文本/卡片) |
| 卡若AI `飞书管理/脚本/soul_party_to_feishu_sheet.py` | 派对运营表同步后的群推送 |
**复盘发哪里**:与 Soul 开发相关的**日终/迭代复盘** → 发 **`FEISHU_DEV_GROUP_WEBHOOK` 对应群**。
彩民/运营另群如需保留,可通过各脚本 `--webhook` 或单独环境变量覆盖。
## 界面截图发群说明
- 飞书自定义机器人发图需先走**应用上传**得到 `image_key`(见 `send_chapter_poster_to_feishu.py` 内逻辑)。
- 无现成截图时:在复盘文本中附 **管理端 / 小程序 / API 文档** 等**可点击链接**,与海报一并发出。
## 与「SKR / 开发群」口头约定
- 群内链接、机器人由**项目侧**维护;**派对 AI** 与 **卡若 AI** 推送配置统一指向本开发群,避免复盘散落多个群。

View File

@@ -7,8 +7,8 @@ description: >
triggers: 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对、派对填表、视频剪辑、一键分发、妙记下载
owner: 水岸
group: 运营
version: "1.1"
updated: "2026-03-21"
version: "1.3"
updated: "2026-03-24"
---
# 卡若创业派对运营 Skill 包
@@ -29,7 +29,7 @@ updated: "2026-03-21"
## 一、技能包组成
本技能包包含以下 4 个核心子技能:
本技能包包含以下 8 个核心子技能:
| # | 技能名 | 文件路径 | 触发词 | 用途 |
|:--|:---|:---|:---|:---|
@@ -37,6 +37,10 @@ updated: "2026-03-21"
| ② | 飞书视频文字下载 | `skills/飞书视频文字下载_SKILL.md` | 妙记下载、飞书视频、飞书妙记 | 文字+视频→本地 |
| ③ | 视频切片 | `skills/视频切片_SKILL.md` | 视频剪辑、切片发布 | 原视频→转录→高光→成片 |
| ④ | 多平台分发 | `skills/多平台分发_SKILL.md` | 一键分发、全平台发布 | 成片→抖音/B站/视频号/小红书/快手 |
| ⑤ | 视频号发布 | `skills/平台_视频号_SKILL.md` | 视频号发布、视频号重传 | 账号校验→清理→定时发布 |
| ⑥ | B站发布 | `skills/平台_B站_SKILL.md` | B站发布、B站补发 | API优先→兜底→定时 |
| ⑦ | 小红书发布 | `skills/平台_小红书_SKILL.md` | 小红书发布、小红书补发 | UI自动化定时发布 |
| ⑧ | 抖音发布 | `skills/平台_抖音_SKILL.md` | 抖音发布、抖音补发 | API定时发布+失败重登 |
---
@@ -112,15 +116,32 @@ python3 "$VIDEO_SCRIPT/soul_slice_pipeline.py" --video "<原视频.mp4>" --clips
```bash
DIST_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本"
# 定时排期第1条立即后续 30-120min 随机间隔
# 默认智能错峰 + 静默(不自动弹窗扫视频号)
python3 "$DIST_SCRIPT/distribute_all.py" --video-dir "<成片目录>"
# 旧版随机间隔:加 --legacy-schedule需要自动扫视频号加 --auto-channels-login
# 立即全部发布
python3 "$DIST_SCRIPT/distribute_all.py" --now
```
**详细流程**:见 `skills/多平台分发_SKILL.md`
#### 视频号发布前置(强制)
在执行视频号发布前,固定做以下 3 步:
1. **账号信息校验**:调用 `auth_data` 校验 `nickname``headImgUrl`,不一致先改到目标值再发。
2. **线上失败/重复清理**:先查 `post_list`,删除失败条目;同标题仅保留最新一条(去重后再补发)。
3. **仅定时发布**:禁止立即发布;若页面定时控件失效,使用 `post_create` 注入定时参数并拦截立即发布。
#### 分发统一总规则(强制)
1. **间隔规则**:按“每一条相邻发布时间”计算,必须在 **10 分钟到 120 分钟** 之间。
2. **账号状态规则**:发布前做账号可用性检查;若出现封禁/登录失效/鉴权失败,先执行重登,再重试失败条。
3. **平台分治规则**:先按平台触发对应子 Skill再执行发布命令不混用平台规则。
4. **收敛规则**:每轮结束输出成功/失败清单 + 重登命令;失败条必须可继续重试直到收敛。
---
## 四、完整流程(派对结束后)
@@ -140,7 +161,7 @@ python3 "$DIST_SCRIPT/distribute_all.py" --now
### Phase 3视频生产
1. **视频切片**:转录 → 高光识别 → 批量切片 → 增强
2. **多平台分发**:成片 → 5平台发布定时排期
2. **多平台分发**:成片 → 5 平台发布(默认智能错峰定时)
### Phase 4文章内容
@@ -167,7 +188,7 @@ python3 auto_log.py
各平台 Cookie 文件位于 `credentials/cookies/` 目录。更新方式:
1. **视频号**浏览器登录后,使用 `cookie_manager.py` 提取
1. **视频号**`channels_login.py`Cursor Simple Browser + 可选 CDP详见 `skills/多平台分发_SKILL.md`
2. **B站**:使用 `bilibili-api-python` 自动获取
3. **小红书/快手**Playwright 自动化登录后提取
@@ -277,5 +298,7 @@ curl -sS -X POST -H "Content-Type: application/json" -d "$TEXT" "$FEISHU_PARTY_C
| 版本 | 日期 | 说明 |
|:---|:---|:---|
| 1.3 | 2026-03-24 | 升级为“总规则+平台子Skill”统一相邻间隔 10~120 分钟;新增账号封禁/重登/重试收敛规则 |
| 1.2 | 2026-03-23 | 新增视频号发布前置三步:头像昵称校验、失败/重复清理、强制定时发布(含请求注入兜底) |
| 1.1 | 2026-03-21 | 新增 §九 闭环复盘发群:卡若五块复盘 + 飞书 Webhook v2msg_type 必填) |
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |

View File

@@ -3,18 +3,25 @@ name: 多平台分发
description: >
一键将视频分发到 5 个平台抖音、B站、视频号、小红书、快手
API 优先策略:视频号纯 API、B站 bilibili-api-python、抖音纯 API。
支持定时排期(第1条立即发后续 30-120 分钟随机间隔)、并行分发、去重、失败自动重试。
支持定时排期(默认智能错峰;可选 legacy、默认静默不弹窗登录、并行分发、去重、失败自动重试。
triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发
owner: 木叶
group: 木
version: "4.0"
updated: "2026-03-11"
version: "4.5"
updated: "2026-03-24"
---
# 多平台分发 Skillv4.0
# 多平台分发 Skillv4.5
> **核心原则**API 发布为主Playwright 为辅。确保确定性地分发到各平台。
> **v4.0 变更**:视频号已切换为纯 API、统一元数据生成器、定时排期优化、简介/标签/分区自动填充
> **v4.5**:统一改为“按相邻条目”间隔控制,默认区间 **10~120 分钟**;并要求按平台子 Skill 执行对应规则后再发布
## 〇、执行原则(第一性原理)
- **视频号两步**:先扫码落盘 Cookie 再上传;`video_channels_resume.py` **默认弹 Chromium 窗**扫码,扫完再继续传;`--silent-login` 无头。
- **目标优先**:全平台分发 → 直接 `distribute_all.py`;视频号助手态需**事先**手动 `channels_login.py` 或显式 `--auto-channels-login`
- **Cookie 优先**:登录成功必须落盘;视频号双路径同步见 `cookie_manager.sync_channels_cookie_files`
- **默认静默**:无人值守跑命令时不弹浏览器。
---
@@ -32,6 +39,8 @@ updated: "2026-03-11"
> 按《视频号与腾讯相关 API 整理》结论,微信官方目前**没有开放「短视频上传/发布」接口**;本 Skill 中的视频号发布能力,属于对 `https://channels.weixin.qq.com` 视频号助手网页协议的逆向封装DFS 上传 + `post_create`),仅在你本机使用,需自行承担协议变更与合规风险。
> 官方可控能力(直播记录、橱窗、留资、罗盘数据、本地生活等)的服务端 API 入口为:`https://developers.weixin.qq.com/doc/channels/api/`,如需做直播/橱窗/留资集成,可基于该文档在单独 Skill 中扩展。
> **「视频号 API token」与成片上传**:公众号 **`access_token`** **不能**替代视频号助手网页态;`channels_api_publish` 依赖 **`channels_storage_state.json`**。127 场静默全平台:`python3 distribute_all.py --video-dir "/Users/karuo/Movies/soul视频/第127场_20260318_output/成片"`(须各平台 Cookie 已就绪)。
---
## 二、一键命令
@@ -39,10 +48,13 @@ updated: "2026-03-11"
```bash
cd /Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本
# 定时排期第1条立即后续 30-120min 随机间隔
# 默认智能错峰排期
python3 distribute_all.py
# 立即全部发布
# 规则间隔(推荐,按相邻条目 10~120 分钟)
python3 distribute_all.py --legacy-schedule --min-gap 10 --max-gap 120
# 立即全部发布(仅在明确要求时)
python3 distribute_all.py --now
# 只发指定平台
@@ -54,24 +66,34 @@ python3 distribute_all.py --video-dir "/path/to/videos/"
# 检查 Cookie / 重试失败
python3 distribute_all.py --check
python3 distribute_all.py --retry
# 需要脚本自动弹窗扫视频号(默认不弹)
python3 distribute_all.py --platforms 视频号 --auto-channels-login --video-dir "/path/to/成片"
# 独立 channels_api_publish 允许自动登录CHANNELS_AUTO_LOGIN=1
# 强制永不自动登录NO_AUTO_CHANNELS_LOGIN=1
# 平台站点上传 CLI平台级含账号检测+自动重登+重试)
python3 site_upload_cli.py check --platforms 抖音 B站 小红书 快手
python3 site_upload_cli.py publish --platforms 抖音 B站 小红书 --video-dir "/path/to/成片" --min-gap 10 --max-gap 120 --until-success
```
---
## 三、定时排期v4.0 优化
## 三、定时排期v4.2+
### 3.1 排期规则
- **第 1 条**:立即发布(`first_delay=0`
- **第 2 条起**:前一条 + random(30, 120) 分钟
- 若总跨度 > 24h自动按比例压缩
- 12 条视频典型跨度 ~10-14h
### 3.1 默认(`generate_smart_schedule`
- 第 1 条立即;间隔与总跨度随条数自适应;本地 07 点尽量挪到午间(`SCHEDULE_NO_NIGHT_REFINE=1` 关闭
- `--legacy-schedule` + `--min-gap` / `--max-gap` / `--max-hours` 为固定区间逻辑(建议 `10~120`
- 去重时排期与目录列表下标对齐
### 3.2 各平台定时实现
### 3.2 独立 `channels_api_publish.py`:同上智能排期转 Unix
### 3.3 各平台定时实现
| 平台 | 定时方式 | 参数 |
|------|----------|------|
| B站 | API `meta.dtime` | Unix 时间戳(秒) |
| 视频号 | API 暂不支持原生定时 | 描述中标注时间/手动设置 |
| 视频号 | API `postTimingInfo.postTime`(秒级 Unix过近时间自动顺延不允许立即发 | `channels_api_publish._scheduled_ts_for_channels` |
| 抖音 | API `timing_ts` | Unix 时间戳 |
| 快手 | Playwright UI | `schedule_helper.py` |
| 小红书 | Playwright UI | `schedule_helper.py` |
@@ -91,12 +113,12 @@ meta.description("B站") # 标题 + 标签 + 品牌标记
meta.tags_str("B站") # AI工具,效率提升,Soul派对,...
meta.bilibili_meta() # B站投稿完整 meta含 tid/tag/desc
meta.title_short() # 小红书短标题≤20字
meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创业派对
meta.hashtags("视频号") # … + #小程序卡若创业派对 #公众号卡若-4点起床的男人
```
### 4.1 内容结构
- **标题**:手工优化标题库优先,否则从文件名智能提取
- **简介**:标题 + 换行 + 话题标签 + `#小程序 卡若创业派对`
- **简介**:标题 + 换行 + 话题标签**视频号**固定追加 `#小程序卡若创业派对` `#公众号卡若-4点起床的男人`(其它平台仍为 `#小程序 卡若创业派对`
- **标签**基于关键词匹配AI/创业/副业/Soul 等 12 类)+ 通用标签
- **分区**B站 tid=160生活>日常)
- **风控过滤**`content_filter.py` 自动替换敏感词70+ 映射,严格/宽松分级)
@@ -122,8 +144,42 @@ meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创
`cookie_manager.py` 统一管理:
- 中央存储:`多平台分发/cookies/{平台}_cookies.json`
- 自动迁移:旧路径 → 中央存储(首次使用时)
- API 预检5 平台各自 auth API 校验有效性
- 防重复登录:有效 Cookie 不触发重新获取
- **视频号双路径**:预检读中央、发布读 legacy`sync_channels_cookie_files()`**mtime 新者覆盖旧者**,避免两份不一致
- **登录后必存**`channels_login.py` 保存 `channels_storage_state.json`**立即 copy**`cookies/视频号_cookies.json`
- **登录页只在 Cursor 内**`channels_login.py` v7 用 `cursor://vscode.simple-browser/show?url=…` 打开 **Simple Browser**,不用系统默认浏览器;**无额外 Chromium** 时需 Cursor 带 `--remote-debugging-port=9223`(或 `CHANNELS_CDP_URL`),脚本 CDP 附着后导出会话;否则 **自动回退** 本机 Chromium 仅用于写 Cookie`--playwright-only` 强制只走 Chromium
- **视频号登录**:默认不自动执行 `channels_login.py`;需要时加 `--auto-channels-login`(或事先手动登录)
- API 预检:各平台 auth API 校验有效性
---
## 六点五、视频号发布前置检查(强制)
每次发布视频号前,必须先跑:
1. `auth/auth_data`:校验 `nickname``headImgUrl`(不一致先改号资料,再执行发布)。
2. `post/post_list`:筛查失败条目并删除。
3. 同标题去重:若存在多条,仅保留最新 `objectId`,其余调用 `post/post_delete` 删除。
4. 发布阶段若页面定时控件失败,改为 `post_create` 请求注入 `postTimingInfo`,继续定时发布;注入也失败则中止该条(防止误发立即)。
---
## 六点六、账号异常自动处理(强制)
1. 发布前先执行 `--check`;若平台 Cookie 无效,先走对应登录脚本。
2. 发布中若出现封禁/风控/鉴权失败,立即输出处理建议与重登命令。
3. 重登完成后优先执行 `--retry`,只重试失败条,不重复成功条。
4. 失败超过 2 轮仍未收敛时,输出平台级阻塞原因并停止盲目重试。
---
## 六点七、平台子Skill分流强制
- 视频号:按 `平台_视频号_SKILL.md` 执行。
- B站`平台_B站_SKILL.md` 执行。
- 小红书:按 `平台_小红书_SKILL.md` 执行。
- 抖音:按 `平台_抖音_SKILL.md` 执行。
多平台联发时先加载各平台子Skill再统一调度 `distribute_all.py`
---

View File

@@ -0,0 +1,34 @@
---
name: 平台_B站发布
description: B站发布专用规则。API优先失败降级 Playwright按相邻间隔 10~120 分钟定时发布。
triggers: B站发布、B站补发、B站重试
owner: 木叶
group: 木
version: "1.0"
updated: "2026-03-24"
---
# 平台子SkillB站发布
## 一、强制规则
1. 默认 API 投稿,失败才降级 Playwright。
2. 发布间隔按相邻条目控制在 `10~120` 分钟。
3. 出现 406/超时等异常时先重试失败条,不重复成功条。
## 二、标准命令
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
--platforms B站 \
--video-dir "<成片目录>" \
--legacy-schedule --min-gap 10 --max-gap 120
```
## 三、登录异常处理
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/B站发布/脚本/bilibili_login.py"
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
```

View File

@@ -0,0 +1,34 @@
---
name: 平台_小红书发布
description: 小红书发布专用规则。UI自动化定时发布按相邻间隔 10~120 分钟执行。
triggers: 小红书发布、小红书补发
owner: 木叶
group: 木
version: "1.0"
updated: "2026-03-24"
---
# 平台子Skill小红书发布
## 一、强制规则
1. 逐条定时发布,禁止批量立即发布。
2. 相邻发布时间间隔必须在 `10~120` 分钟。
3. 若仅返回 likely_published需在下一轮巡检确认状态。
## 二、标准命令
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
--platforms 小红书 \
--video-dir "<成片目录>" \
--legacy-schedule --min-gap 10 --max-gap 120
```
## 三、登录异常处理
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/小红书发布/脚本/xiaohongshu_login.py"
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
```

View File

@@ -0,0 +1,34 @@
---
name: 平台_抖音发布
description: 抖音发布专用规则。API定时发布按相邻间隔 10~120 分钟;遇风控/封禁优先账号处理。
triggers: 抖音发布、抖音补发、抖音重试
owner: 木叶
group: 木
version: "1.0"
updated: "2026-03-24"
---
# 平台子Skill抖音发布
## 一、强制规则
1. 仅按定时发布,不做整批立即。
2. 相邻发布时间间隔 `10~120` 分钟。
3. 若出现封禁/风控提示,立刻停止盲目重试并提示账号处理。
## 二、标准命令
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
--platforms 抖音 \
--video-dir "<成片目录>" \
--legacy-schedule --min-gap 10 --max-gap 120
```
## 三、登录异常处理
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/抖音发布/脚本/douyin_login.py"
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
```

View File

@@ -0,0 +1,38 @@
---
name: 平台_视频号发布
description: 视频号发布专用规则。发布前校验账号,清理密集/失败条目,按相邻间隔 10~120 分钟定时发布。
triggers: 视频号发布、视频号重传、视频号补发
owner: 木叶
group: 木
version: "1.0"
updated: "2026-03-24"
---
# 平台子Skill视频号发布
## 一、强制规则
1. 发布前必须校验 `auth_data`(昵称、头像、登录态)。
2. 发布前必须检查 `post_list`:失败条目删除、同标题去重。
3. 仅允许定时发布,按相邻条目间隔 `10~120` 分钟。
4. 定时控件失败时允许请求注入;注入失败则中止该条,禁止误发立即。
## 二、标准命令
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频号发布/脚本/channels_web_cli.py" \
publish-dir \
--video-dir "<成片目录>" \
--legacy-schedule --min-gap 10 --max-gap 120 \
--start-after-min 10
```
## 三、登录异常处理
- 若出现 `300334` / `300002` / `finder_raw` 缺失:立即执行重登。
- 重登命令:
```bash
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频号发布/脚本/channels_login.py" --playwright-only
```

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ soul-api/soul-api-new
# Cursor 索引减负db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径)
.cursor/scripts/db-exec/node_modules/
# 本地技能包临时打包目录
.tmp_skill_bundle/

View File

@@ -9,10 +9,10 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.1'
// 章节总数API 获取失败时的统一兜底,避免 90/62 混用
const FALLBACK_TOTAL_SECTIONS = 62
const APP_DISPLAY_VERSION = '1.7.2'
App({
globalData: {
@@ -42,7 +42,7 @@ App({
// 书籍数据bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters
bookData: null,
totalSections: FALLBACK_TOTAL_SECTIONS, // 来自 book/parts 或 book/stats失败时用常量
totalSections: 90,
// 购买记录
purchasedSections: [],
@@ -98,32 +98,34 @@ App({
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
// 登录过期401 后用户点「去登录」时设为 true我的页 onShow 会检测并自动弹登录
pendingLoginAfterExpire: false,
/** MBTI → 默认头像 URL/api/miniprogram/config/mbti-avatars供推广海报等 */
mbtiAvatarsMap: {},
mbtiAvatarsExpires: 0,
},
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const PRODUCTION = 'https://soulapi.quwanzhi.com'
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
} else {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
},
@@ -397,8 +399,8 @@ App({
if (!this.globalData.isSinglePageMode) return true
wx.showModal({
title: '请前往完整小程序',
content: '当前朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
title: '请打开完整小程序',
content: '当前朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
showCancel: false,
confirmText: '我知道了',
})
@@ -414,7 +416,7 @@ App({
},
/**
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* 头像/昵称未改:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit避免乱跳
*/
checkAvatarNicknameAndGuide() {
@@ -433,10 +435,10 @@ App({
if (lastDate === today) return
wx.setStorageSync('lastAvatarGuideDate', today)
wx.showModal({
title: '完善个人资料',
content: '请设置头像昵称,让其他创业者更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '头像昵称会出现在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
@@ -594,7 +596,7 @@ App({
/**
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit避免与 avatar-nickname 乱跳。
* 旧数据VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
* 旧数据VIP 但头像昵称未改):说明原因后 redirectTo profile-edit
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
@@ -623,12 +625,12 @@ App({
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,弹窗「为了更好服务,请完善资料」
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit
if (needsAvatarNickname) {
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请完善资料',
confirmText: '去完善',
title: '补全对外展示信息',
content: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -641,9 +643,9 @@ App({
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '完善资料',
content: 'VIP会员需完善手机号以便使用找伙伴、提现等功能',
confirmText: '去完善',
title: '补全手机号',
content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -651,12 +653,12 @@ App({
})
return
}
// 有手机号但缺微信号:弹窗引导(非强制)
// 有手机号但缺微信号:可选补全(非强制)
wx.showModal({
title: '完善联系方式',
content: '请到资料页完善微信号,便于他人联系您',
confirmText: '去完善',
cancelText: '稍后',
title: '补全微信号(可选)',
content: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。',
confirmText: '去填写',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
@@ -695,6 +697,17 @@ App({
if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) {
return this.globalData.configCache
}
if (!forceRefresh && !this.globalData.configCache) {
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
const exp = Number(local && local.expiresAt)
if (local && local.data && exp > now) {
this.globalData.configCache = local.data
this.globalData.configCacheExpires = exp
return local.data
}
} catch (_) {}
}
try {
const [coreRes, auditRes] = await Promise.all([
this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }),
@@ -713,11 +726,45 @@ App({
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (e) {
if (this.globalData.configCache) return this.globalData.configCache
}
} catch (_) {}
try {
const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
if (legacy) {
const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {}
const res = {
success: legacy.success !== false,
prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {},
features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {},
userDiscount: legacy.userDiscount,
mpConfig: cfg,
configs: legacy.configs || {}
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (_) {}
if (this.globalData.configCache) return this.globalData.configCache
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
if (local && local.data) return local.data
} catch (_) {}
return null
},
@@ -780,27 +827,58 @@ App({
async loadMpConfig() {
try {
const res = await this.getConfig()
if (!res) return
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
this.globalData.auditMode = !!mp.auditMode
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
// 通知当前已加载的页面刷新 auditMode从后台切回时配置更新后立即生效
try {
const pages = getCurrentPages()
pages.forEach(p => {
if (p && p.data && 'auditMode' in p.data) {
p.setData({ auditMode: this.globalData.auditMode || false })
}
})
} catch (_) {}
if (res) {
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
this.globalData.auditMode = !!mp.auditMode
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
}
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
}
// 审核模式不走 5min 本地 config 缓存:始终以独立接口为准,避免后台已开审核端仍显示支付入口
try {
await this.getAuditMode()
} catch (_) {}
this.loadMbtiAvatarsMap()
},
/** 拉取后台配置的 16 型 MBTI 默认头像(公开接口,约 5 分钟本地缓存) */
async loadMbtiAvatarsMap() {
try {
const now = Date.now()
if (this.globalData.mbtiAvatarsExpires && this.globalData.mbtiAvatarsExpires > now) return
const res = await this.request({
url: '/api/miniprogram/config/mbti-avatars',
silent: true,
timeout: 8000,
})
if (res && res.success && res.avatars && typeof res.avatars === 'object') {
this.globalData.mbtiAvatarsMap = res.avatars
this.globalData.mbtiAvatarsExpires = now + 5 * 60 * 1000
}
} catch (e) {
console.warn('[App] loadMbtiAvatarsMap:', e?.message || e)
}
},
/** 展示用头像:优先用户头像,否则 MBTI 映射(需已 loadMbtiAvatarsMap */
resolveAvatarWithMbti(avatar, mbti) {
try {
const { resolveAvatarWithMbti } = require('./utils/mbtiAvatar.js')
return resolveAvatarWithMbti(
avatar,
mbti,
this.globalData.mbtiAvatarsMap || {},
this.globalData.baseUrl || ''
)
} catch (_) {
return (avatar && String(avatar).trim()) || ''
}
},
/**
@@ -855,6 +933,79 @@ App({
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
_shouldFallbackToProduction(err) {
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
return (
msg.includes('connection_failed') ||
msg.includes('err_proxy_connection_failed') ||
msg.includes('dns') ||
msg.includes('name not resolved') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused') ||
msg.includes('network') ||
msg.includes('timeout')
)
},
_switchBaseUrlToProduction() {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
} catch (_) {}
},
_requestOnce(url, options = {}, silent = false) {
const showError = (msg) => {
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
showError(msg)
reject(new Error(msg))
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
return
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg)
? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试')
: '网络异常,请重试'
showError(msg)
reject(err || new Error(msg))
}
})
})
},
/**
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
* GET 请求 200ms 内相同 url 去重,避免并发重复请求。
@@ -874,98 +1025,21 @@ App({
}
const method = (options.method || 'GET').toUpperCase()
const silent = !!options.silent
const showError = (msg) => {
if (!silent && msg) {
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
}
// GET 短时去重:相同 url 的并发请求共享同一 promise
if (method === 'GET') {
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
const pending = this._requestPending || (this._requestPending = {})
if (pending[dedupKey]) {
return pending[dedupKey].promise
}
if (pending[dedupKey]) return pending[dedupKey].promise
}
const promise = new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
// 业务失败success === falsesoul-api 用 message 或 error 返回原因
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
// 登录态不一致:本地有 token/userInfo但后端查不到该用户
// 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
const err = new Error(msg)
err.response = data
const skipToast = data.needBindWechat === true || data.needBind === true ||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0)
if (!silent && !skipToast) showError(msg)
reject(err)
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
if (!silent) {
wx.showModal({
title: '登录已过期',
content: '请重新登录后继续使用',
confirmText: '去登录',
cancelText: '稍后',
success: (modalRes) => {
if (modalRes.confirm) {
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
const route = (cur && cur.route) || ''
if (route === 'pages/my/my' && typeof cur.showLogin === 'function') {
cur.showLogin()
} else {
this.globalData.pendingLoginAfterExpire = true
wx.switchTab({ url: '/pages/my/my' })
}
}
}
})
} else {
showError('未授权,请重新登录')
}
reject(new Error('未授权'))
return
}
// 4xx/5xx优先用返回体的 message/error
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
const err = new Error(msg)
if (data && typeof data === 'object') err.response = data
const skipToast = data && (data.needBindWechat === true || data.needBind === true ||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0))
if (!silent && !skipToast) showError(msg)
reject(err)
},
fail: (err) => {
const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试'
showError(msg)
reject(new Error(msg))
}
})
const promise = this._requestOnce(url, options, silent).catch(async (err) => {
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
this._switchBaseUrlToProduction()
return this._requestOnce(url, options, silent)
}
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
})
if (method === 'GET') {
@@ -1134,11 +1208,6 @@ App({
if (res.success && res.data) {
const user = res.data.user
const oid = res.data.openId || user.openId
if (oid) {
this.globalData.openId = oid
wx.setStorageSync('openId', oid)
}
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
@@ -1169,7 +1238,6 @@ App({
} else {
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
setTimeout(() => this.connectWsHeartbeat(), 2000)
}
return res.data
@@ -1209,15 +1277,30 @@ App({
wx.setStorageSync('readSectionIds', list)
},
/**
* 记录「最近打开过」某章节(含仅预览/未登录),供「我的」最近阅读展示;
* 不写入 readSectionIds避免把「点开预览」算进已读章节数。
*/
touchRecentSection(sectionId) {
if (!sectionId) return
try {
let list = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(list)) list = []
const now = Date.now()
list = list.filter((x) => x && x.id !== sectionId)
list.unshift({ id: String(sectionId), t: now })
wx.setStorageSync('recent_section_opens', list.slice(0, 40))
} catch (_) {}
},
// 已读章节数(用于首页展示)
getReadCount() {
return (this.globalData.readSectionIds || []).length
},
// 获取章节总数(优先 API 已加载值,失败时返回统一兜底常量)
// 获取章节总数
getTotalSections() {
const v = this.globalData.totalSections
return (v != null && v > 0) ? v : FALLBACK_TOTAL_SECTIONS
return this.globalData.totalSections
},
// 切换TabBar

View File

@@ -4,8 +4,8 @@
"login-modal": "/components/login-modal/login-modal"
},
"pages": [
"pages/chapters/chapters",
"pages/index/index",
"pages/chapters/chapters",
"pages/match/match",
"pages/my/my",
"pages/read/read",
@@ -14,6 +14,7 @@
"pages/privacy/privacy",
"pages/referral/referral",
"pages/purchases/purchases",
"pages/reading-records/reading-records",
"pages/settings/settings",
"pages/search/search",
"pages/addresses/addresses",
@@ -29,7 +30,8 @@
"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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -6,14 +6,14 @@
<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}}">
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-wechat-hover">
<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>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
</view>
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
<view class="login-agree-row" catchtap="onToggleAgree">

View File

@@ -134,3 +134,7 @@
text-decoration: underline;
padding: 0 4rpx;
}
/* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */
.btn-wechat-hover { opacity: 0.92; }
.privacy-agree-btn-hover { opacity: 0.88; }

View File

@@ -87,7 +87,14 @@ Component({
console.log('[TabBar] res对象keys:', Object.keys(res || {}))
}
this.setData({ matchEnabled }, () => {
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
const list = [...this.data.list]
if (tabUi.home) list[0] = { ...list[0], text: String(tabUi.home) }
if (tabUi.chapters) list[1] = { ...list[1], text: String(tabUi.chapters) }
if (tabUi.match) list[2] = { ...list[2], text: String(tabUi.match) }
if (tabUi.my) list[3] = { ...list[3], text: String(tabUi.my) }
this.setData({ matchEnabled, list }, () => {
console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
// 配置加载完成后,根据当前路由设置选中状态
this.updateSelected()

View File

@@ -61,7 +61,7 @@
</view>
</view>
<!-- 联系方式 - 引导到Soul派对房 -->
<!-- 联系方式 - Soul 派对房 -->
<view class="contact-card" wx:if="{{!authorLoading}}">
<text class="card-title">联系作者</text>
<view class="contact-item">

View File

@@ -1,6 +1,6 @@
/**
* 卡若创业派对 - 头像昵称引导
* 登录后资料未完善时引导用户修改默认头像昵称,仅包含头像+昵称两项
* 卡若创业派对 - 头像昵称设置
* 登录后若仍为默认头像/昵称,在此修改;仅头像昵称两项
*/
const app = getApp()
@@ -12,11 +12,29 @@ Page({
saving: false,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 规则引擎传入avatar | nickname用于高亮对应区块 */
uiFocus: '',
},
onLoad() {
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
const focus = String(options.focus || '').toLowerCase()
if (focus === 'avatar' || focus === 'nickname') {
this.setData({ uiFocus: focus })
}
this.loadFromUser()
if (focus === 'nickname') {
setTimeout(() => {
if (typeof wx.requirePrivacyAuthorize === 'function') {
wx.requirePrivacyAuthorize({
success: () => this.setData({ nicknameInputFocus: true }),
fail: () => {},
})
} else {
this.setData({ nicknameInputFocus: true })
}
}, 400)
}
},
loadFromUser() {

View File

@@ -1,4 +1,4 @@
{
"navigationBarTitleText": "完善资料",
"navigationBarTitleText": "头像与昵称",
"usingComponents": {}
}

View File

@@ -1,22 +1,23 @@
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
<!--卡若创业派对 - 头像昵称设置页-->
<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>
<text class="nav-title">完善资料</text>
<text class="nav-title">头像与昵称</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content">
<!-- 引导文案 -->
<view class="guide-card">
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
<text class="guide-title">完善头像和昵称</text>
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
<text class="guide-title">设置对外展示信息</text>
<text class="guide-desc" wx:if="{{uiFocus === 'avatar'}}">请先换一张清晰头像,伙伴更容易认出你。</text>
<text class="guide-desc" wx:elif="{{uiFocus === 'nickname'}}">请改一个真实好记的昵称,方便伙伴称呼你。</text>
<text class="guide-desc" wx:else>头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
<view class="avatar-section">
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
@@ -29,7 +30,7 @@
</view>
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
<view class="form-section">
<view class="form-section {{uiFocus === 'nickname' ? 'section-focus' : ''}}">
<text class="form-label">昵称</text>
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
<input
@@ -62,7 +63,7 @@
</view>
<view class="link-row" bindtap="goToFullProfile">
<text class="link-text">完善更多资料</text>
<text class="link-text">编辑完整档案</text>
<icon name="chevron-right" size="28" color="#00CED1" customClass="link-arrow"></icon>
</view>
</view>

View File

@@ -1,4 +1,4 @@
/* 卡若创业派对 - 头像昵称引导页 */
/* 卡若创业派对 - 头像昵称设置页 */
.page {
background: #050B14;
min-height: 100vh;
@@ -78,6 +78,13 @@
gap: 32rpx;
margin-bottom: 48rpx;
}
.avatar-section.section-focus .avatar-wrap {
box-shadow: 0 0 0 6rpx rgba(94, 234, 212, 0.45), 0 0 36rpx rgba(94, 234, 212, 0.45);
}
.form-section.section-focus .form-input-wrap {
border-color: rgba(94, 234, 212, 0.55);
box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25);
}
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;

View File

@@ -7,6 +7,8 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { partEmojiForBodyIndex } = require('../../utils/partIcons.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
@@ -26,6 +28,7 @@ Page({
// 展开状态
expandedPart: null,
bookCollapsed: false,
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
@@ -47,7 +50,11 @@ Page({
partsLoading: true,
// 功能配置(搜索开关)
searchEnabled: true
searchEnabled: true,
// mp_config.mpUi.chaptersPage
chaptersBookTitle: '一场SOUL的创业实验场',
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
},
onLoad() {
@@ -62,10 +69,20 @@ Page({
this.loadFeatureConfig()
},
_applyChaptersMpUi() {
const c = app.globalData.configCache?.mpConfig?.mpUi?.chaptersPage || {}
this.setData({
chaptersBookTitle: String(c.bookTitle || '一场SOUL的创业实验场').trim() || '一场SOUL的创业实验场',
chaptersBookSubtitle: String(c.bookSubtitle || '来自Soul派对房的真实商业故事').trim() ||
'来自Soul派对房的真实商业故事'
})
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
this._applyChaptersMpUi()
return
}
const res = await app.getConfig()
@@ -74,8 +91,10 @@ Page({
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
this.setData({ searchEnabled })
this._applyChaptersMpUi()
} catch (e) {
this.setData({ searchEnabled: true })
this._applyChaptersMpUi()
}
},
@@ -92,7 +111,6 @@ Page({
totalSections = res.totalSections ?? 0
fixedSections = res.fixedSections || []
}
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
const fixedMap = {}
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
const appendixList = [
@@ -100,22 +118,21 @@ Page({
{ id: 'appendix-2', title: '附录2创业者自检清单', mid: fixedMap['appendix-2'] },
{ id: 'appendix-3', title: '附录3本书提到的工具和资源', mid: fixedMap['appendix-3'] }
]
const getPartBadge = (p, idx) => {
if (p.partLabel) return p.partLabel
const title = p.title || ''
const m = title.match(/^第(.+?)篇/)
if (m) return m[1].trim()
if (p.id === 'part-2026-daily') return '派'
return numbers[idx] || String(idx + 1)
}
const bookData = parts.map((p, idx) => ({
id: p.id,
number: getPartBadge(p, idx),
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [] // 展开时懒加载
}))
const bookData = parts.map((p, idx) => {
let icon = String(p.icon || '').trim()
if (icon && !isSafeImageSrc(icon)) icon = ''
const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx)
return {
id: p.id,
icon,
iconEmoji,
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
chapters: [],
alwaysShow: (p.title || '').indexOf('每日派对干货') > -1
}
})
app.globalData.totalSections = totalSections
this.setData({
bookData,
@@ -194,6 +211,7 @@ Page({
},
onShow() {
this._applyChaptersMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
@@ -233,7 +251,11 @@ Page({
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
},
// 切换展开状态,展开时懒加载该篇章章节
toggleBookCollapse() {
trackClick('chapters', 'btn_click', '折叠书名')
this.setData({ bookCollapsed: !this.data.bookCollapsed })
},
async togglePart(e) {
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
const partId = e.currentTarget.dataset.id

View File

@@ -34,18 +34,21 @@
</view>
</view>
<!-- 书籍信息卡 -->
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}">
<!-- 书籍信息卡(点击折叠/展开除"每日派对干货"外的篇章) -->
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}" bindtap="toggleBookCollapse">
<view class="book-icon">
<view class="book-icon-inner"><icon name="book-open" size="56" color="#ffffff"></icon></view>
</view>
<view class="book-info">
<text class="book-title">一场SOUL的创业实验场</text>
<text class="book-subtitle">来自Soul派对房的真实商业故事</text>
<text class="book-title">{{chaptersBookTitle}}</text>
<text class="book-subtitle">{{chaptersBookSubtitle}}</text>
</view>
<view class="book-count">
<text class="count-value brand-color">{{totalSections}}</text>
<text class="count-label">章节</text>
<view class="book-right-area">
<view class="book-count">
<text class="count-value brand-color">{{totalSections}}</text>
<text class="count-label">章节</text>
</view>
<text class="book-collapse-hint">{{bookCollapsed ? '展开 ▸' : '折叠 ▾'}}</text>
</view>
</view>
@@ -65,11 +68,13 @@
<!-- 篇章列表 -->
<view class="part-list">
<view class="part-item" wx:for="{{bookData}}" wx:key="id">
<view class="part-item" wx:for="{{bookData}}" wx:key="id" wx:if="{{!bookCollapsed || item.alwaysShow}}">
<!-- 篇章标题 -->
<view class="part-header" bindtap="togglePart" data-id="{{item.id}}">
<view class="part-left">
<view class="part-icon">{{item.number}}</view>
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFill"/>
<view wx:elif="{{item.iconEmoji}}" class="part-icon part-icon-emoji">{{item.iconEmoji}}</view>
<view wx:else class="part-icon">{{item.title[0] || '篇'}}</view>
<view class="part-info">
<text class="part-title">{{item.title}}</text>
<text class="part-subtitle">{{item.subtitle}}</text>

View File

@@ -195,20 +195,11 @@
color: rgba(255, 255, 255, 0.4);
}
.book-count {
text-align: right;
}
.count-value {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.count-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);
}
.book-right-area { display: flex; flex-direction: column; align-items: flex-end; gap: 8rpx; }
.book-count { text-align: right; }
.count-value { font-size: 40rpx; font-weight: 700; display: block; }
.count-label { font-size: 20rpx; color: rgba(255, 255, 255, 0.4); }
.book-collapse-hint { font-size: 20rpx; color: #00CED1; opacity: 0.7; }
/* ===== 目录内容 ===== */
.chapters-content {
@@ -365,6 +356,18 @@
color: #ffffff;
flex-shrink: 0;
}
/* 与管理端 ChapterTree 篇头 emoji 一致 */
.part-icon-emoji {
font-size: 34rpx;
font-weight: 400;
line-height: 1;
color: #ffffff;
background: linear-gradient(135deg, #1e3a4a 0%, #0f172a 100%);
border: 2rpx solid rgba(0, 206, 209, 0.35);
}
.part-icon-img {
width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0;
}
.part-info {
display: flex;

View File

@@ -7,6 +7,11 @@
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png'
const KARUO_USER_ID = 'ogpTW5Wbbo9DfSyB3-xCWN6EGc-g'
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
@@ -81,7 +86,20 @@ Page({
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false
auditMode: false,
// mp_config.mpUi.homePage后台系统设置 mpUi
mpUiLogoTitle: '卡若创业派对',
mpUiLogoSubtitle: '来自派对房的真实故事',
mpUiLinkKaruoText: '点击链接卡若',
/** 最终展示:后台 linkKaruoAvatar 或本包默认卡若照片 */
mpUiLinkKaruoDisplay: DEFAULT_KARUO_LINK_AVATAR,
mpUiSearchPlaceholder: '搜索章节标题或内容...',
mpUiBannerTag: '推荐',
mpUiBannerReadMore: '点击阅读',
mpUiSuperTitle: '超级个体',
mpUiPickTitle: '精选推荐',
mpUiLatestTitle: '最新新增'
},
onLoad(options) {
@@ -107,6 +125,7 @@ Page({
onShow() {
console.log('[Index] onShow 触发')
this.setData({ auditMode: app.globalData.auditMode || false })
this._applyHomeMpUi()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
@@ -301,27 +320,65 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
_applyHomeMpUi() {
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
let linkKaruoAvatar = String(h.linkKaruoAvatar || h.linkKaruoImage || '').trim()
if (linkKaruoAvatar && !isSafeImageSrc(linkKaruoAvatar)) linkKaruoAvatar = ''
this.setData({
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
mpUiLinkKaruoDisplay: linkKaruoAvatar || DEFAULT_KARUO_LINK_AVATAR,
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
})
if (!linkKaruoAvatar) this._loadKaruoAvatarLazy()
},
_loadKaruoAvatarLazy() {
app.request({ url: `/api/miniprogram/user/profile?userId=${KARUO_USER_ID}`, silent: true, timeout: 3000 })
.then(res => {
if (res?.success && res.data?.avatar && isSafeImageSrc(res.data.avatar)) {
this.setData({ mpUiLinkKaruoDisplay: res.data.avatar })
}
})
.catch(() => {})
},
async loadFeatureConfig() {
try {
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
if (hasCachedFeatures) {
this.setData({
searchEnabled: app.globalData.features.searchEnabled,
auditMode: app.globalData.auditMode || false
})
return
if (!hasCachedFeatures) {
const res = await app.getConfig()
const features = (res && res.features) || (res && res.data && res.data.features) || {}
const searchEnabled = features.searchEnabled !== false
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled
if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled
const mp = (res && res.mpConfig) || {}
app.globalData.auditMode = !!mp.auditMode
}
const res = await app.getConfig()
const features = (res && res.features) || {}
const mp = (res && res.mpConfig) || {}
const searchEnabled = features.searchEnabled !== false
const auditMode = !!mp.auditMode
if (!app.globalData.features) app.globalData.features = {}
app.globalData.features.searchEnabled = searchEnabled
app.globalData.auditMode = auditMode
this.setData({ searchEnabled, auditMode })
await app.getAuditMode()
const searchEnabled = app.globalData.features?.searchEnabled !== false
this.setData({
searchEnabled,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
} catch (e) {
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
try {
await app.getAuditMode()
} catch (_) {}
this.setData({
searchEnabled: app.globalData.features?.searchEnabled !== false,
auditMode: app.globalData.auditMode || false
})
this._applyHomeMpUi()
}
},
@@ -414,6 +471,22 @@ Page({
// 阻止弹窗内部点击事件冒泡到遮罩层
stopPropagation() {},
preventMove() {},
onLeadPrivacyAuthorize() {
this.onAgreePrivacyForLead()
},
onDisagreePrivacyForLead() {
if (app._privacyResolve) {
try {
app._privacyResolve({ event: 'disagree' })
} catch (_) {}
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
onLeadPhoneInput(e) {
this.setData({ leadPhone: (e.detail.value || '').trim() })
},

View File

@@ -12,14 +12,14 @@
<text class="logo-text">派</text>
</view>
<view class="logo-info">
<text class="logo-title-text">卡若创业派对</text>
<text class="logo-subtitle">来自派对房的真实故事</text>
<text class="logo-title-text">{{mpUiLogoTitle}}</text>
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
</view>
</view>
<view class="header-right">
<view class="contact-btn" bindtap="onLinkKaruo">
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-name">点击链接卡若</text>
<view class="header-right" wx:if="{{!auditMode}}">
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
</view>
</view>
</view>
@@ -29,7 +29,7 @@
<view class="search-icon-wrap">
<icon name="search" size="40" color="#8e8e93" customClass="search-icon-text"></icon>
</view>
<text class="search-placeholder">搜索章节标题或内容...</text>
<text class="search-placeholder">{{mpUiSearchPlaceholder}}</text>
</view>
</view>
@@ -38,26 +38,25 @@
<!-- Banner 推荐卡片(优先 recommended API 第一条) -->
<view class="banner-card" wx:if="{{bannerSection}}" bindtap="goToRead" data-id="{{bannerSection.id}}" data-mid="{{bannerSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-tag">{{mpUiBannerTag}}</view>
<view class="banner-title">{{bannerSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">点击阅读</text>
<text class="banner-action-text">{{mpUiBannerReadMore}}</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">{{mpUiBannerTag}}</view>
<view class="banner-title">加载中...</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 class="banner-action"><text class="banner-action-text">{{mpUiBannerReadMore}}</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
</view>
<!-- 超级个体:与匹配页一致,仅 VIP 横向列表(无首位特例) -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">超级个体</text>
<text class="section-subtitle">获客入口</text>
<text class="section-title">{{mpUiSuperTitle}}</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -100,7 +99,7 @@
<!-- 精选推荐:默认 3 条,列表下三角展开更多(与最新新增一致) -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<text class="section-title">{{mpUiPickTitle}}</text>
</view>
<view class="featured-list">
<view
@@ -134,7 +133,7 @@
<!-- 最新新增(时间线样式;超过 5 条时在列表下方点小三角展开,展开后随页面滚动看全部) -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<text class="section-title">{{mpUiLatestTitle}}</text>
</view>
<view class="timeline-wrap">
<view class="timeline-line"></view>
@@ -165,17 +164,22 @@
<!-- 底部留白 -->
<view class="bottom-space"></view>
<!-- 链接卡若 - 留资弹窗(未填手机/微信号时):一键获取 + 手动输入 -->
<!-- 隐私授权(首页在 needPrivacy 列表内,需有遮罩否则无法完成 agree -->
<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-privacy-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onLeadPrivacyAuthorize">同意</button>
<view class="privacy-cancel" bindtap="onDisagreePrivacyForLead">拒绝</view>
</view>
</view>
<!-- 链接卡若 - 留资弹窗 -->
<view class="lead-mask" wx:if="{{showLeadModal}}" catchtap="closeLeadModal">
<!-- 使用 catchtap="stopPropagation" 阻止内部点击冒泡到遮罩层,避免点击输入框时弹窗被关闭 -->
<view class="lead-box" catchtap="stopPropagation">
<text class="lead-title">留下联系方式</text>
<text class="lead-desc">方便卡若与您联系</text>
<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>
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
<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

@@ -85,6 +85,10 @@
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.logo-title {
@@ -963,6 +967,61 @@
height: 40rpx;
}
/* ===== 隐私授权(与 avatar-nickname 对齐) ===== */
.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: #000;
font-size: 30rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
}
.privacy-btn::after { border: none; }
.privacy-cancel {
margin-top: 24rpx;
font-size: 28rpx;
color: #64748B;
}
/* ===== 链接卡若 - 留资弹窗 ===== */
.lead-mask {
position: fixed;
@@ -971,7 +1030,7 @@
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
z-index: 2100;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -5,17 +5,28 @@
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge.js')
const { checkAndExecute } = require('../../utils/ruleEngine.js')
const { trackClick } = require('../../utils/trackClick')
/** 是否视为未设置头像(勿用 includes('132'):微信 CDN 合法头像 URL 普遍含 /132/ 尺寸段) */
function isMissingOrPlaceholderAvatar(avatarUrl, hasAvatarFromServer) {
if (hasAvatarFromServer === true || hasAvatarFromServer === 1) return false
const a = (avatarUrl || '').trim()
if (!a) return true
const u = a.toLowerCase()
if (u.includes('default')) return true
// 微信默认占位常见以 /0 结尾;/132/ 为正常尺寸路径,不能当作占位
if (/\/0($|[?#])/.test(u)) return true
return false
}
// 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
// 资源对接:需要登录+购买章节才能使用填写2项信息我能帮到你什么、我需要什么帮助
// 导师顾问:跳转到存客宝添加微信
// 团队招募:跳转到存客宝添加微信
let MATCH_TYPES = [
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'handshake', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'star', matchFromDB: true, showJoinAfterMatch: false },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: 'users', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: 'heart', matchFromDB: true, showJoinAfterMatch: true },
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: 'gamepad', matchFromDB: true, showJoinAfterMatch: true }
@@ -227,19 +238,21 @@ Page({
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const avatar = res?.data?.avatarUrl || app.globalData.userInfo?.avatarUrl || ''
const isDefaultAvatar = !avatar || avatar.includes('default') || avatar.includes('132')
if (isDefaultAvatar) {
const d = res?.data || {}
const avatar = (d.avatar || d.avatarUrl || app.globalData.userInfo?.avatar || app.globalData.userInfo?.avatarUrl || '').trim()
const hasAvatarFlag = d.hasAvatar === true || d.hasAvatar === 1
if (isMissingOrPlaceholderAvatar(avatar, hasAvatarFlag)) {
wx.showModal({
title: '完善头像',
content: '请先设置头像后再使用匹配功能',
confirmText: '去设置',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
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()
const phone = (d.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechat = (d.wechatId || d.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
callback()
return
@@ -414,6 +427,16 @@ Page({
// 开始匹配 - 只匹配数据库中的真实用户
async startMatch() {
const uidEarly = app.globalData.userInfo?.id
if (!uidEarly) {
wx.showModal({
title: '需要登录',
content: '找伙伴匹配需登录账号,请先登录',
confirmText: '去登录',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) },
})
return
}
this.setData({
isMatching: true,
matchAttempts: 0,
@@ -425,55 +448,52 @@ Page({
this.setData({ matchAttempts: this.data.matchAttempts + 1 })
}, 1000)
// 从数据库获取真实用户匹配
// 从数据库获取真实用户匹配(带上手机/微信写入 match_records与流量池运营对齐
let matchedUser = null
let matchProfileError = ''
let matchFailHint = ''
const uid = app.globalData.userInfo?.id || ''
const phoneForMatch = (this.data.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechatForMatch = (this.data.wechatId || wx.getStorageSync('user_wechat') || '').trim()
try {
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
const res = await app.request({
url: '/api/miniprogram/match/users',
silent: true,
method: 'POST',
data: {
matchType: this.data.selectedType,
userId: app.globalData.userInfo?.id || ''
}
userId: uid,
phone: phoneForMatch || undefined,
wechatId: wechatForMatch || undefined,
},
})
if (res.success && res.data) {
matchedUser = res.data
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
} else if (res && !res.success) {
matchFailHint = res.message || res.error || ''
if (res.code === 'QUOTA_EXCEEDED') {
matchFailHint = matchFailHint || '今日免费次数已用完,可购买额外匹配次数后再试'
} else if (res.code === 'NO_USERS') {
matchFailHint = matchFailHint || '当前流量池暂无可匹配用户,可稍后再试;补全档案后匹配范围通常更大。'
}
}
} catch (e) {
console.log('[Match] 数据库匹配失败:', e)
const r = e.response || {}
if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
matchProfileError = r.message || '请先完善手机号或微信号后再发起匹配'
}
matchFailHint = (e && e.message) ? String(e.message) : '网络异常,请稍后重试'
}
// 延迟显示结果(模拟匹配过程)
const delay = Math.random() * 2000 + 2000
setTimeout(() => {
clearInterval(timer)
if (matchProfileError) {
this.setData({ isMatching: false })
wx.showModal({
title: '完善资料',
content: matchProfileError,
confirmText: '去完善',
showCancel: false,
success: (mr) => {
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
// 如果没有匹配到用户,提示用户
if (!matchedUser) {
this.setData({ isMatching: false })
wx.showModal({
title: '暂无匹配',
content: '当前暂无合适的匹配用户,请稍后再试',
content: matchFailHint || '当前暂无合适的匹配用户,请稍后再试',
showCancel: false,
confirmText: '知道了'
})
@@ -484,13 +504,6 @@ Page({
const newCount = this.data.todayMatchCount + 1
const matchesRemaining = this.data.hasFullBook ? 999999 : Math.max(0, this.data.totalMatchesAllowed - newCount)
// 规范化 commonInterestsemoji 或无效 icon 转为 SVG 图标名
const normalized = matchedUser.commonInterests?.map(item => ({
...item,
icon: /^[a-z0-9-]+$/i.test(item.icon) ? item.icon : 'target'
})) || []
if (normalized.length) matchedUser = { ...matchedUser, commonInterests: normalized }
this.setData({
isMatching: false,
currentMatch: matchedUser,
@@ -525,7 +538,7 @@ Page({
}
}
})
// 匹配后规则:引导填写 MBTI/行业信息
// 匹配后规则:资料未齐时提示补全(服务端 profile 合并,见 ruleEngine
checkAndExecute('after_match', this)
} catch (e) {
console.log('上报匹配失败:', e)
@@ -652,27 +665,7 @@ Page({
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
const r = e.response || {}
if (r.errorCode === 'ERR_REQUIRE_PURCHASE') {
wx.showModal({
title: '需要先购买',
content: r.message || '请先购买章节或解锁全书后再使用资源对接',
confirmText: '去购买',
cancelText: '取消',
success: (mr) => { if (mr.confirm) this.goToChapters() }
})
} else if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
wx.showModal({
title: '完善资料',
content: r.message || '请先完善资料',
confirmText: '去完善',
success: (mr) => {
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
} else {
wx.showToast({ title: e.message || '网络异常,请重试', icon: 'none' })
}
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
this.setData({ isJoining: false })
}
@@ -710,7 +703,9 @@ Page({
return
}
const referralCode = soulBridge.getReferralCodeForPay(app)
// 邀请码:与章节支付一致,写入订单便于分销归属与对账
const referralCode = wx.getStorageSync('referral_code') || ''
// 调用支付接口购买匹配次数
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -723,11 +718,17 @@ Page({
referralCode: referralCode || undefined
}
})
if (res.success && res.data?.payParams) {
await soulBridge.requestWxJsapiPayment(res.data.payParams)
await soulBridge.syncOrderStatusQuery(app, res.data.orderSn)
// 调用微信支付
await new Promise((resolve, reject) => {
wx.requestPayment({
...res.data.payParams,
success: resolve,
fail: reject
})
})
// 支付成功,增加匹配次数
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
wx.setStorageSync('extra_match_count', extraMatches)

View File

@@ -121,7 +121,8 @@
<!-- 用户卡片 -->
<view class="match-card">
<view class="card-header">
<image class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<image wx:if="{{currentMatch.avatar}}" class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
<view wx:else class="match-avatar match-avatar-fallback"><text>{{currentMatch.nickname ? currentMatch.nickname[0] : '?'}}</text></view>
<view class="match-info">
<text class="match-name">{{currentMatch.nickname}}</text>
<view class="match-tags">

View File

@@ -445,6 +445,20 @@
flex-shrink: 0;
}
.match-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 206, 209, 0.15);
box-sizing: border-box;
}
.match-avatar-fallback text {
font-size: 48rpx;
font-weight: 600;
color: #00CED1;
}
.match-info {
flex: 1;
min-width: 0;

View File

@@ -5,22 +5,74 @@
* mbti, region, industry, position, businessScale, skills,
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
* helpOffer→canHelp, helpNeed→needHelp
* 点头像:若后台 persons.user_id 已绑定则带 ckbLeadToken走存客宝 CKBLead与阅读页 @ 一致)
* 点头像:登录后依次校验本人头像(非默认)、微信号、绑定手机号,再弹「链接「昵称」」;有 ckbLeadToken 走人物获客计划,否则走全局留资
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true },
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
const sb = app.globalData.statusBarHeight || 44
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44 })
const myId = app.globalData.userInfo?.id
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
if (options.id) this.loadMember(options.id)
},
/** 本人名片:去完整编辑资料(单页) */
goMyProfileEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
},
async loadMember(id) {
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const myId = app.globalData.userInfo?.id
const isOwn = !!(myId && id != null && String(id) === String(myId))
if (isOwn && app.globalData.isLoggedIn && myId) {
try {
const res = await app.request({
url: `/api/miniprogram/user/profile?userId=${encodeURIComponent(String(id))}`,
silent: true
})
if (res?.success && res.data) {
const d = res.data
this.setData({
member: this.enrichAndFormat({
id: d.id,
nickname: d.nickname,
name: d.nickname,
avatar: d.avatar,
phone: d.phone,
wechatId: d.wechatId || d.wechat_id,
isVip: !!app.globalData.isVip,
mbti: d.mbti,
region: d.region,
industry: d.industry,
position: d.position,
businessScale: d.businessScale || d.business_scale,
skills: d.skills,
storyBestMonth: d.storyBestMonth || d.story_best_month,
storyAchievement: d.storyAchievement || d.story_achievement,
storyTurning: d.storyTurning || d.story_turning,
helpOffer: d.helpOffer || d.help_offer,
helpNeed: d.helpNeed || d.help_need,
projectIntro: d.projectIntro || d.project_intro,
ckbLeadToken: d.ckbLeadToken || d.ckb_lead_token
}),
loading: false
})
return
}
} catch (e) {}
}
try {
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
if (res?.success && res.data) {
@@ -65,10 +117,20 @@ Page({
enrichAndFormat(raw) {
const e = (v) => this._emptyIfPlaceholder(v)
const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || ''
let dispAv = isSafeImageSrc(rawAv) ? String(rawAv).trim() : ''
if (!dispAv) {
dispAv = resolveAvatarWithMbti(
'',
raw.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
}
const merged = {
id: raw.id,
name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者',
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
avatar: dispAv,
isVip: !!(raw.isVip || raw.is_vip),
mbti: e(raw.mbti),
region: e(raw.region),
@@ -153,50 +215,233 @@ Page({
return false
},
/** 链接前:头像需非空且非默认图;微信号需已填写(与 app._needsAvatarNickname 中头像规则一致) */
_hasCustomAvatarForLink(u) {
const avatar = (u && (u.avatar || u.avatarUrl) || '').trim()
return !!avatar && !avatar.includes('default')
},
_hasWechatFilledForLink(u) {
const w = (u && (u.wechatId || u.wechat_id) || wx.getStorageSync('user_wechat') || '').trim()
return w.length > 0
},
/** 拉取最新 profile 写回 globalData返回合并后的访客资料片段 */
async _refreshVisitorProfileForLink() {
const base = app.globalData.userInfo
if (!base?.id) return base || {}
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${base.id}`, silent: true })
if (profileRes?.success && profileRes.data) {
const d = profileRes.data
const updated = { ...app.globalData.userInfo }
if (d.avatar != null && String(d.avatar).trim()) updated.avatar = String(d.avatar).trim()
if (d.wechatId != null) updated.wechatId = d.wechatId
if (d.wechat_id != null) updated.wechat_id = d.wechat_id
app.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
if (d.wechatId) wx.setStorageSync('user_wechat', String(d.wechatId).trim())
return updated
}
} catch (e) {}
return base
},
async _ensureVisitorReadyForMemberLink() {
const u = await this._refreshVisitorProfileForLink()
const avatarOk = this._hasCustomAvatarForLink(u)
const wechatOk = this._hasWechatFilledForLink(u)
if (avatarOk && wechatOk) return true
const miss = []
if (!avatarOk) miss.push('头像')
if (!wechatOk) miss.push('微信号')
wx.showModal({
title: '补全本人档案',
content: `链接前请补全本人${miss.join('与')},便于对方识别与安全对接。`,
confirmText: '去填写',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return false
},
/** 链接前:必须已绑定大陆手机号(与留资接口校验一致) */
async _ensurePhoneBoundForLink(myUserId) {
const { phone } = await this._resolveLeadPhoneWechat(myUserId)
if (phone && /^1[3-9]\d{9}$/.test(phone)) return true
wx.showModal({
title: '请先绑定手机号',
content: '链接对方前需绑定本人手机号,便于跟进与对接。',
confirmText: '去绑定',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return false
},
/**
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
* 否则:解锁后复制微信/手机号并引导
* 点头像:登录 → 头像+微信号 → 手机号 均校验通过后弹窗说明;确认后 POST ckb/lead。
* 有 ckbLeadToken 走人物计划;无 token 走全局留资。对方已公开联系方式时可取消后在下方自行添加。
*/
startLinkFlow() {
async startLinkFlow() {
if (this.data.isOwnProfile) return
const member = this.data.member
if (!member) return
const leadTok = (member.ckbLeadToken || '').trim()
if (leadTok) {
const nickname = ((member.name || 'TA').trim() || 'TA')
const nickname = (member.name || 'TA').trim() || 'TA'
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '添加好友',
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
confirmText: '确定',
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
}
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
if (member.wechatRaw || member.wechatDisplay) {
if (!this._ensureUnlockedForLink('wechat')) return
const m = this.data.member
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
return
const myUserId = app.globalData.userInfo.id
wx.showLoading({ title: '请稍候', mask: true })
let profileOk = false
let phoneOk = false
try {
profileOk = await this._ensureVisitorReadyForMemberLink()
if (profileOk) phoneOk = await this._ensurePhoneBoundForLink(myUserId)
} finally {
wx.hideLoading()
}
if (member.contactRaw || member.contactDisplay) {
if (!this._ensureUnlockedForLink('contact')) return
const m = this.data.member
if (m.contactFull) this._copyAndGuidePhone(m.contactFull)
return
}
wx.showToast({ title: '暂未公开联系方式', icon: 'none' })
if (!profileOk || !phoneOk) return
const leadTok = (member.ckbLeadToken || '').trim()
const content = leadTok
? `确定后提交联系方式,平台将按对方配置跟进;智能助手与人工协同协助对接。`
: `智能助手与人工会协同跟进,协助您对接「${nickname}」。\n\n若对方已公开手机或微信,可先点「取消」,在页面下方自行添加。`
wx.showModal({
title: `链接「${nickname}`,
content,
confirmText: '确定链接',
cancelText: '取消',
success: (r) => {
if (!r.confirm) return
if (leadTok) this._doCkbLeadSubmit(leadTok, nickname, member.id, nickname)
else this._doGlobalMemberLeadSubmit(member)
}
})
},
/** 与阅读页 @mention 同链路soulBridge.submitCkbLead */
async _doCkbLeadSubmit(targetUserId, targetNickname) {
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
source: 'member_detail_avatar',
phoneModalContent: '请先填写手机号(必填),以便对方通过获客计划联系您'
})
/** 无人物 token 时:全局留资,便于运营侧主动加好友并协助链接该会员 */
async _doGlobalMemberLeadSubmit(member) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于工作人员联系您、协助链接该超级个体。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetNickname: '',
targetMemberId: member.id || undefined,
targetMemberName: (member.name || '').trim() || undefined,
source: 'member_detail_global',
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,请留意工作人员联系', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
async _resolveLeadPhoneWechat(myUserId) {
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 || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
return { phone, wechatId }
},
/** 与 read 页 _doMentionAddFriend 一致targetUserId = Person.token可选带超级个体 userId 写入留资 params */
async _doCkbLeadSubmit(targetUserId, targetNickname, targetMemberId, targetMemberName) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方通过获客计划联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source: 'member_detail_avatar'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
_ensureUnlockedForLink(field) {
@@ -321,11 +566,56 @@ Page({
goToVip() { wx.navigateTo({ url: '/pages/vip/vip' }) },
goBack() { getApp().goBackOrToHome() },
/**
* 分享标题姓名MBTI有则加擅长无擅长时用个人故事或职业一行。总长控制避免微信卡片截断难看。
*/
_buildMemberShareTitle(maxLen = 56) {
const m = this.data.member
if (!m) return '卡若创业派对 · 超级个体'
const clip = (s, n) => {
if (!s) return ''
const t = String(s).replace(/\s+/g, ' ').trim()
return t.length <= n ? t : t.slice(0, n - 1) + '…'
}
const name = clip((m.name || '创业者').trim() || '创业者', 14)
const mbti = (m.mbti || '').trim()
const skills = (m.skills || '').trim()
const achievement = (m.achievement || '').trim()
const bestMonth = (m.bestMonth || '').trim()
const turning = (m.turningPoint || '').trim()
const story = achievement || bestMonth || turning || ''
const jobLine = [m.industry, m.position].filter(Boolean).join('·')
const sep = ''
const nameSeg = name
const mbtiSeg = mbti ? clip(mbti, 8) : ''
const usedBase = nameSeg.length + (mbtiSeg ? sep.length + mbtiSeg.length : 0)
const willTail = !!(skills || story || jobLine || m.region)
const restBudget = Math.max(6, maxLen - usedBase - (willTail ? sep.length : 0))
let tail = ''
if (skills) tail = clip(skills, restBudget)
else if (story) tail = clip(story, restBudget)
else if (jobLine) tail = clip(jobLine, restBudget)
else if (m.region) tail = clip(m.region, restBudget)
let title = nameSeg
if (mbtiSeg) title += sep + mbtiSeg
if (tail) title += sep + tail
title = clip(title.replace(/\s+/g, ' '), maxLen)
if (!title || title.length < 3) title = clip(`${nameSeg}${sep}超级个体`, maxLen)
return title
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
const id = this.data.member?.id
const title = this._buildMemberShareTitle(64)
return {
title: '卡若创业派对 - 创业者详情',
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'
}
},
@@ -333,7 +623,13 @@ Page({
onShareTimeline() {
const ref = app.getMyReferralCode()
const id = this.data.member?.id
const m = this.data.member
const q = id ? (ref ? `id=${id}&ref=${ref}` : `id=${id}`) : (ref ? `ref=${ref}` : '')
return { title: '卡若创业派对 - 创业者详情', query: q }
const title = this._buildMemberShareTitle(56)
const res = { title, query: q }
if (m && m.avatar && /^https?:\/\//.test(String(m.avatar))) {
res.imageUrl = m.avatar
}
return res
}
})

View File

@@ -1,20 +1,29 @@
<!-- 卡若创业派对 - 超级个体详情(居中头像区 + 低调联系方式 + 信息卡) -->
<!-- 卡若创业派对 - 超级个体详情(点头像申请对接 + 有则展示联系方式 + 信息卡) -->
<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="#5EEAD4" customClass="nav-icon"></icon>
</view>
<text class="nav-title">个人资料</text>
<view class="nav-placeholder"></view>
<text class="nav-title">{{isOwnProfile ? '我的名片' : '个人资料'}}</text>
<view class="nav-edit-wrap" wx:if="{{isOwnProfile}}" bindtap="goMyProfileEdit">
<text class="nav-edit-text">编辑</text>
</view>
<view class="nav-placeholder" wx:else></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{navBarTotalPx}}px);" wx:if="{{member}}">
<!-- 首屏:居中头像 + 昵称 + 标签;点头像走添加微信引导(无独立「链接 TA」大按钮 -->
<!-- 首屏:点头像申请对接;超级个体未填手机/微信则整块不展示联系方式 -->
<view class="shell">
<view class="shell-glow"></view>
<view class="hero-profile">
<view class="hero-avatar-block" bindtap="startLinkFlow" hover-class="hero-avatar-block-hover" hover-stay-time="80">
<view
class="hero-avatar-block"
wx:if="{{!isOwnProfile}}"
bindtap="startLinkFlow"
hover-class="hero-avatar-block-hover"
hover-stay-time="80"
>
<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"/>
@@ -30,10 +39,39 @@
<text>{{member.region}}</text>
</view>
</view>
<text class="avatar-link-hint-one">点头像 · 申请对接</text>
</view>
<view
class="hero-avatar-block hero-avatar-block-self"
wx:else
bindtap="goMyProfileEdit"
hover-class="hero-avatar-block-hover"
hover-stay-time="80"
>
<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 && member.name[0]) || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
<text class="profile-name">{{member.name}}</text>
<view class="profile-tags profile-tags-modern" wx:if="{{member.mbti || member.region}}">
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
<view class="tag tag-region" wx:if="{{member.region}}">
<icon name="map-pin" size="22" color="currentColor" customClass="pin-icon"></icon>
<text>{{member.region}}</text>
</view>
</view>
<text class="avatar-link-hint-one self-hint">这是我的超级个体名片 · 可转发分享 · 点头像去编辑</text>
</view>
</view>
<view class="contact-rows contact-rows-subtle">
<view
class="contact-rows contact-rows-subtle"
wx:if="{{member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}"
>
<view class="contact-sec-label">联系方式</view>
<view
class="link-chip link-chip-subtle {{member.contactUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.contactRaw || member.contactDisplay}}"
@@ -69,10 +107,6 @@
<icon wx:if="{{!member.wechatUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
</view>
</view>
<view class="link-empty link-empty-subtle" wx:if="{{!(member.contactRaw || member.contactDisplay) && !(member.wechatRaw || member.wechatDisplay)}}">
<text class="link-empty-txt">暂未公开联系方式</text>
</view>
</view>
</view>

View File

@@ -43,6 +43,23 @@
.nav-placeholder {
width: 72rpx;
}
.nav-edit-wrap {
min-width: 72rpx;
padding: 12rpx 8rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
.nav-edit-text {
font-size: 28rpx;
font-weight: 600;
color: #5eead4;
}
.self-hint {
font-size: 22rpx !important;
line-height: 1.45;
padding: 0 20rpx;
}
.scroll-wrap {
box-sizing: border-box;
@@ -75,7 +92,7 @@
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 8rpx;
padding-bottom: 4rpx;
}
.hero-avatar-block {
@@ -88,6 +105,27 @@
opacity: 0.94;
}
.avatar-link-hint-one {
margin-top: 16rpx;
padding: 0 24rpx;
font-size: 24rpx;
font-weight: 400;
color: rgba(148, 163, 184, 0.88);
text-align: center;
line-height: 1.4;
max-width: 100%;
box-sizing: border-box;
}
.contact-sec-label {
font-size: 22rpx;
font-weight: 600;
color: rgba(148, 163, 184, 0.7);
letter-spacing: 4rpx;
margin-bottom: 8rpx;
padding-left: 6rpx;
}
.contact-rows {
position: relative;
z-index: 1;
@@ -98,8 +136,8 @@
}
.contact-rows-subtle {
margin-top: 24rpx;
padding-top: 24rpx;
margin-top: 28rpx;
padding-top: 28rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
@@ -153,7 +191,7 @@
font-weight: 800;
padding: 6rpx 12rpx;
border-radius: 12rpx;
z-index: 2;
z-index: 5;
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.35);
}
@@ -273,26 +311,6 @@
display: block;
}
.link-empty {
padding: 24rpx;
border-radius: 20rpx;
background: rgba(15, 23, 42, 0.4);
border: 1rpx dashed rgba(148, 163, 184, 0.2);
}
.link-empty-subtle {
padding: 16rpx 8rpx;
background: transparent;
border: none;
}
.link-empty-txt {
font-size: 24rpx;
color: #64748b;
}
.link-empty-subtle .link-empty-txt {
font-size: 22rpx;
color: rgba(100, 116, 139, 0.75);
}
.profile-name {
position: relative;
z-index: 1;

View File

@@ -8,27 +8,8 @@ const app = getApp()
const { formatStatNum } = require('../../utils/util.js')
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
/** 与 referral 一致:提现需已绑定微信号(便于到账核对) */
function hasWechatIdBound() {
const ui = app.globalData.userInfo
return !!(ui && (ui.wechat || ui.wechatId || wx.getStorageSync('user_wechat')))
}
/** 是否视为「单章解锁」类订单(排除全书/VIP 等聚合商品名) */
function isSectionUnlockOrder(o) {
const name = String(o.product_name || o.title || '').trim()
if (/全书|全書|VIP|会员|年费|买断/.test(name)) return false
const pid = String(o.product_id || o.section_id || o.sectionId || '')
if (/^\d+\.\d+/.test(pid)) return true
return !!pid && pid.length > 0
}
function parseOrderTimeMs(o) {
const raw = o.created_at || o.createdAt || o.pay_time || 0
const t = new Date(raw).getTime()
return Number.isFinite(t) ? t : 0
}
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
Page({
data: {
@@ -39,9 +20,11 @@ Page({
// 用户状态
isLoggedIn: false,
userInfo: null,
/** 我的页头像展示:微信头像或 MBTI 映射图 */
profileAvatarDisplay: '',
// 统计数据
totalSections: 0, // 来自 app.getTotalSections() 或 dashboard-stats
totalSections: 62,
readCount: 0,
referralCount: 0,
earnings: '-',
@@ -103,10 +86,12 @@ Page({
// 我的余额
walletBalanceText: '--',
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
// mp_config.mpUi.myPage后台可改文案/跳转
mpUiCardLabel: '名片',
mpUiVipLabelVip: '会员中心',
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
},
onLoad() {
@@ -136,11 +121,37 @@ Page({
}
}
this.initUserStatus()
// 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗
if (app.globalData.pendingLoginAfterExpire) {
app.globalData.pendingLoginAfterExpire = false
setTimeout(() => this.showLogin(), 100)
}
this._applyMyMpUiLabels()
},
_getMyPageUi() {
const cache = app.globalData.configCache || {}
const fromNew = cache?.mpConfig?.mpUi?.myPage
if (fromNew && typeof fromNew === 'object') return fromNew
const fromLegacy = cache?.configs?.mp_config?.mpUi?.myPage
if (fromLegacy && typeof fromLegacy === 'object') return fromLegacy
return {}
},
_applyMyMpUiLabels() {
const my = this._getMyPageUi()
this.setData({
mpUiCardLabel: String(my.cardLabel || '名片').trim() || '名片',
mpUiVipLabelVip: String(my.vipLabelVip || '会员中心').trim() || '会员中心',
mpUiVipLabelGuest: String(my.vipLabelGuest || '成为会员').trim() || '成为会员',
mpUiReadStatLabel: String(my.readStatLabel || '已读章节').trim() || '已读章节',
mpUiRecentTitle: String(my.recentReadTitle || '最近阅读').trim() || '最近阅读'
})
},
async _refreshMyAvatarDisplay(safeUser) {
if (!safeUser || !app.globalData.isLoggedIn) return
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const url = app.resolveAvatarWithMbti ? app.resolveAvatarWithMbti(safeUser.avatar, safeUser.mbti) : ''
if (!this.data.isLoggedIn) return
this.setData({ profileAvatarDisplay: url || '' })
},
async loadFeatureConfig() {
@@ -151,13 +162,16 @@ Page({
const referralEnabled = features.referralEnabled !== false
const searchEnabled = features.searchEnabled !== false
const mp = (res && res.mpConfig) || {}
const auditMode = !!mp.auditMode
app.globalData.auditMode = auditMode
app.globalData.auditMode = !!mp.auditMode
await app.getAuditMode()
const auditMode = app.globalData.auditMode || false
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
this._applyMyMpUiLabels()
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
this._applyMyMpUiLabels()
}
},
@@ -169,15 +183,22 @@ Page({
const userId = userInfo.id || ''
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
const safeUser = { ...userInfo }
if (!isSafeImageSrc(safeUser.avatar)) safeUser.avatar = ''
app.globalData.userInfo = safeUser
try {
wx.setStorageSync('userInfo', safeUser)
} catch (_) {}
// 先设基础信息;阅读统计与收益再分别从后端刷新
this.setData({
isLoggedIn: true,
userInfo,
userInfo: safeUser,
profileAvatarDisplay: '',
userIdShort,
userWechat,
readCount: 0,
referralCount: userInfo.referralCount || 0,
referralCount: 0,
earnings: '-',
pendingEarnings: '-',
earningsLoading: true,
@@ -193,12 +214,14 @@ Page({
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadUnlockedChapters()
this._refreshMyAvatarDisplay(safeUser)
} else {
const guestReadCount = app.getReadCount()
const guestRecent = this._mergeRecentChaptersFromLocal([])
this.setData({
isLoggedIn: false,
userInfo: null,
profileAvatarDisplay: '',
userIdShort: '',
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
@@ -206,10 +229,7 @@ Page({
earnings: '-',
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
recentChapters: guestRecent,
totalReadTime: 0,
matchHistory: 0,
totalReadTimeText: '0',
@@ -218,88 +238,83 @@ Page({
}
},
/**
* 已解锁章节:优先订单接口(按支付时间倒序);失败时用 purchasedSections + bookData 兜底
*/
async loadUnlockedChapters() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) {
this.setData({
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false
})
return
}
const userId = app.globalData.userInfo.id
const expanded = this.data.unlockedExpanded
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const metaById = (id) => {
const row = bookFlat.find((s) => s.id === id)
return {
mid: row?.mid ?? row?.MID ?? 0,
title: cleanSingleLineField(row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '')
}
}
/** 本地已打开的章节 idreading_progress 键 + 历史 readSectionIds用于与服务端合并展示 */
_localSectionIdsFromStorage() {
try {
const res = await app.request({ url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`, silent: true })
let rows = []
if (res && res.success && Array.isArray(res.data)) {
rows = res.data
.map((item) => ({
id: item.product_id || item.section_id,
mid: item.section_mid ?? item.mid ?? item.MID ?? 0,
title: cleanSingleLineField(item.product_name || ''),
_ts: parseOrderTimeMs(item)
}))
.filter((r) => r.id && isSectionUnlockOrder({ product_id: r.id, product_name: r.title }))
}
rows.sort((a, b) => b._ts - a._ts)
const seen = new Set()
const deduped = []
for (const r of rows) {
if (seen.has(r.id)) continue
seen.add(r.id)
const meta = metaById(r.id)
deduped.push({
id: r.id,
mid: r.mid || meta.mid,
title: cleanSingleLineField(r.title || meta.title || `章节 ${r.id}`)
})
}
if (deduped.length === 0) {
const ids = [...(app.globalData.purchasedSections || [])]
ids.reverse()
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
} catch (e) {
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const seen = new Set()
const deduped = []
for (const id of ids) {
if (!id || seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
const progressData = wx.getStorageSync('reading_progress') || {}
const fromProgress = Object.keys(progressData).filter(Boolean)
let fromReadList = []
try {
const rs = wx.getStorageSync('readSectionIds')
if (Array.isArray(rs)) fromReadList = rs.filter(Boolean)
} catch (_) {}
return [...new Set([...fromProgress, ...fromReadList])]
} catch (_) {
return []
}
},
expandUnlockedChapters() {
if (this.data.unlockedExpanded) return
trackClick('my', 'tab_click', '已解锁章节_展开')
const full = this.data.unlockedChaptersFull || []
/** 接口无最近阅读时,用本地 reading_progress + recent_section_opens + bookData 补全 */
_mergeRecentChaptersFromLocal(apiList) {
const normalized = Array.isArray(apiList)
? apiList.map((item) => ({
id: item.id,
mid: item.mid,
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
}))
: []
if (normalized.length > 0) return normalized
try {
const progressData = wx.getStorageSync('reading_progress') || {}
let opens = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(opens)) opens = []
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const titleOf = (id) => {
const row = bookFlat.find((s) => s.id === id)
return cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
}
const midOf = (id) => {
const row = bookFlat.find((s) => s.id === id)
return row?.mid ?? row?.MID ?? 0
}
const latest = new Map()
const bump = (sid, ts) => {
if (!sid) return
const id = String(sid)
const t = typeof ts === 'number' && !isNaN(ts) ? ts : 0
const prev = latest.get(id) || 0
if (t >= prev) latest.set(id, t)
}
Object.keys(progressData).forEach((id) => {
const row = progressData[id]
bump(id, row?.lastOpenAt || row?.last_open_at || 0)
})
opens.forEach((o) => bump(o && o.id, o && o.t))
return [...latest.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([id]) => ({ id, mid: midOf(id), title: titleOf(id) }))
} catch (e) {
return []
}
},
/** 接口失败或无 data 时,仅用本地 reading_progress / readSectionIds 刷新已读与最近 */
_hydrateReadStatsFromLocal() {
const localExtra = this._localSectionIdsFromStorage()
const readSectionIds = [...new Set(localExtra.filter(Boolean))]
app.globalData.readSectionIds = readSectionIds
try {
wx.setStorageSync('readSectionIds', readSectionIds)
} catch (_) {}
const recentChapters = this._mergeRecentChaptersFromLocal([])
const readCount = readSectionIds.length
this.setData({
unlockedExpanded: true,
displayUnlockedChapters: full
readCount,
readCountText: formatStatNum(readCount),
recentChapters
})
},
@@ -313,21 +328,29 @@ Page({
silent: true
})
if (!res?.success || !res.data) return
if (!res?.success || !res.data) {
this._hydrateReadStatsFromLocal()
return
}
const apiIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
const localExtra = this._localSectionIdsFromStorage()
const prevGlobal = Array.isArray(app.globalData.readSectionIds) ? app.globalData.readSectionIds.filter(Boolean) : []
const readSectionIds = [...new Set([...apiIds, ...prevGlobal, ...localExtra])]
const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : []
app.globalData.readSectionIds = readSectionIds
wx.setStorageSync('readSectionIds', readSectionIds)
const recentChapters = Array.isArray(res.data.recentChapters)
const apiRecent = Array.isArray(res.data.recentChapters)
? res.data.recentChapters.map((item) => ({
id: item.id,
mid: item.mid,
title: item.title || `章节 ${item.id}`
}))
: []
const recentChapters = this._mergeRecentChaptersFromLocal(apiRecent)
const readCount = Number(res.data.readCount || 0)
const readCount = readSectionIds.length
const totalReadTime = Number(res.data.totalReadMinutes || 0)
const matchHistory = Number(res.data.matchHistory || 0)
const orderCount = Number(res.data.orderCount || 0)
@@ -345,6 +368,7 @@ Page({
})
} catch (e) {
console.log('[My] 拉取阅读统计失败:', e && e.message)
this._hydrateReadStatsFromLocal()
}
},
@@ -585,7 +609,11 @@ Page({
wx.showToast({ title: '已刷新', icon: 'success' })
},
// 微信原生获取头像button open-type="chooseAvatar" 回调,点击头像直接唤起选择器)
tapAvatar() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
@@ -632,6 +660,7 @@ Page({
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
this._refreshMyAvatarDisplay(userInfo)
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
@@ -677,9 +706,9 @@ Page({
}
},
// 点击昵称:跳转资料编辑页type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面
// 点击昵称:先进个人资料名片页,再在右上角进入编辑(与需求「编辑收进名片流」一致
editNickname() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
},
// 关闭昵称弹窗
@@ -867,9 +896,33 @@ Page({
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
// 跳转到目录
goToChapters() {
// 已读章节:进入阅读记录页(有列表);路径可由 mpUi.myPage.readStatPath 配置
goToReadStat() {
trackClick('my', 'nav_click', '已读章节')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const p = String(this._getMyPageUi().readStatPath || '').trim()
if (p && navigateMpPath(p)) return
navigateMpPath('/pages/reading-records/reading-records?focus=all')
},
/** 最近阅读区块标题点击:进入阅读记录(最近维度) */
goToRecentReadHub() {
trackClick('my', 'nav_click', '最近阅读区块')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const p = String(this._getMyPageUi().recentReadPath || '').trim()
if (p && navigateMpPath(p)) return
navigateMpPath('/pages/reading-records/reading-records?focus=recent')
},
// 去目录(空状态等)
goToChapters() {
trackClick('my', 'nav_click', '去目录')
wx.switchTab({ url: '/pages/chapters/chapters' })
},
@@ -880,14 +933,17 @@ Page({
},
// 跳转到推广中心(需登录)
goToReferral() {
trackClick('my', 'nav_click', '推广中心')
goToReferral(e) {
const focus = e && e.currentTarget && e.currentTarget.dataset ? (e.currentTarget.dataset.focus || '') : ''
const action = focus === 'bindings' ? '推荐好友' : focus === 'earnings' ? '我的收益' : '推广中心'
trackClick('my', 'nav_click', action)
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
if (!this.data.referralEnabled) return
wx.navigateTo({ url: '/pages/referral/referral' })
const url = focus ? `/pages/referral/referral?focus=${focus}` : '/pages/referral/referral'
wx.navigateTo({ url })
},
// 退出登录
@@ -943,10 +999,22 @@ Page({
goToVip() {
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const p = String(this._getMyPageUi().vipPath || '').trim()
if (p && navigateMpPath(p)) return
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
// 本人对外名片:默认与「超级个体」同款 member-detailmpUi.myPage.cardPath 可覆盖(需含完整 query
goToMySuperCard() {
trackClick('my', 'btn_click', '名片')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const uid = this.data.userInfo?.id
if (!uid) return
const p = String(this._getMyPageUi().cardPath || '').trim()
if (p && navigateMpPath(p)) return
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
},
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -967,19 +1035,7 @@ Page({
wx.showToast({ title: '暂无可提现金额', icon: 'none' })
return
}
if (!hasWechatIdBound()) {
wx.showModal({
title: '请先绑定微信号',
content: '提现需先绑定微信号,便于到账核对。请到「设置」中绑定后再提现。',
confirmText: '去绑定',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
this.doWithdraw(amount)
await this.ensureContactInfo(() => this.doWithdraw(amount))
},
async doWithdraw(amount) {
@@ -998,16 +1054,6 @@ Page({
this.loadWalletBalance()
} catch (e) {
wx.hideLoading()
const r = e.response || {}
if (r.needBind || r.needBindWechat) {
wx.showModal({
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (mr) => { if (mr.confirm) wx.navigateTo({ url: '/pages/settings/settings' }) }
})
return
}
wx.showToast({ title: e.message || '提现失败', icon: 'none' })
}
}

View File

@@ -1,10 +1,7 @@
<!-- 我的页 - 设计稿 1:1 还原 -->
<view class="page">
<!-- 顶部导航:左侧资料编辑 + 标题 -->
<!-- 顶部导航 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-settings" bindtap="goToProfileEdit">
<image class="nav-settings-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
</view>
<text class="nav-title">我的</text>
</view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
@@ -23,36 +20,30 @@
<view class="profile-card" wx:else>
<view class="profile-card-inner">
<view class="profile-top-row">
<view class="avatar-wrap">
<view class="avatar-wrap" bindtap="tapAvatar">
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<image wx:if="{{profileAvatarDisplay}}" class="avatar-img" src="{{profileAvatarDisplay}}" 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">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="profile-name-actions">
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
</view>
</view>
<view class="vip-tags" wx:if="{{!auditMode}}">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
</view>
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
</view>
</view>
<view class="profile-stats-row">
<view class="profile-stat" bindtap="goToChapters">
<view class="profile-stat" bindtap="goToReadStat">
<text class="profile-stat-val">{{readCountText || '0'}}</text>
<text class="profile-stat-label">已读章节</text>
<text class="profile-stat-label">{{mpUiReadStatLabel}}</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral" data-focus="bindings">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
@@ -60,7 +51,7 @@
<text class="profile-stat-val">{{matchHistoryText}}</text>
<text class="profile-stat-label">匹配伙伴</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral" data-focus="earnings">
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
@@ -117,43 +108,13 @@
</view>
</view>
<!-- 已解锁:仅低调图标区(无标题文案);默认 5 条 + 倒三角展开;倒序由接口/JS 保证 -->
<view class="card recent-card unlocked-card" wx:if="{{unlockedChaptersFull.length > 0}}">
<view class="unlocked-section-head">
<image class="unlocked-section-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="recent-list">
<view
class="recent-item"
wx:for="{{displayUnlockedChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="recent-left">
<text class="recent-index">{{index + 1}}</text>
<text class="recent-title">{{item.title}}</text>
</view>
<text class="recent-link">阅读</text>
</view>
</view>
<view
class="unlocked-expand-hint"
wx:if="{{unlockedChaptersFull.length > 5 && !unlockedExpanded}}"
bindtap="expandUnlockedChapters"
hover-class="unlocked-expand-hint-hover"
hover-stay-time="80"
>
<view class="unlocked-expand-triangle"></view>
</view>
</view>
<!-- 已解锁/充值/代付等流水已迁至「我的订单」页 -->
<!-- 最近阅读 -->
<view class="card recent-card">
<view class="card-header">
<view class="card-header" bindtap="goToRecentReadHub">
<image class="card-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
<text class="card-title">最近阅读</text>
<text class="card-title">{{mpUiRecentTitle}}</text>
</view>
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
<view

View File

@@ -73,23 +73,51 @@
}
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.profile-name-row { display: flex; align-items: center; justify-content: flex-start; gap: 16rpx; flex-wrap: wrap; }
.user-name {
font-size: 44rpx; font-weight: bold; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
}
.become-member-btn {
padding: 12rpx 28rpx; border: 2rpx solid #C8A146; color: #C8A146;
.profile-actions-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
/* 名片/会员中心:紧挨昵称下方(在 profile-meta 内) */
.profile-actions-under-name { margin-top: 4rpx; }
/* 名片 / 会员中心:统一品牌青,与 tabBar 选中色一致 */
.profile-action-btn {
padding: 12rpx 28rpx; border: 2rpx solid #4FD1C5; color: #4FD1C5;
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.become-member-vip { border-color: rgba(200,161,70,0.5); color: rgba(200,161,70,0.8); }
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
.vip-tag {
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
}
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
.profile-action-btn:active { opacity: 0.75; }
.user-wechat { font-size: 26rpx; color: #6B7280; }
.super-card-entry {
position: relative;
margin-top: 24rpx;
padding: 24rpx 56rpx 24rpx 28rpx;
border-radius: 16rpx;
background: rgba(79, 209, 197, 0.08);
border: 1rpx solid rgba(79, 209, 197, 0.28);
}
.super-card-entry-txt {
font-size: 28rpx;
font-weight: 600;
color: #4fd1c5;
display: block;
}
.super-card-entry-sub {
font-size: 22rpx;
color: #9ca3af;
margin-top: 8rpx;
display: block;
}
.super-card-entry-arrow {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 40rpx;
color: rgba(255, 255, 255, 0.35);
font-weight: 300;
}
.profile-stats-row {
display: flex; justify-content: space-around; margin-top: 32rpx;
padding-top: 24rpx; border-top: 1rpx solid #374151;
@@ -98,6 +126,15 @@
.profile-stat-val { display: block; font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
.profile-stat-label { display: block; font-size: 22rpx; color: #6B7280; margin-top: 8rpx; }
.profile-edit-bar {
display: flex; align-items: center; gap: 16rpx;
margin-top: 24rpx; padding: 20rpx 24rpx;
background: rgba(79,209,197,0.06); border-radius: 12rpx;
}
.profile-edit-icon { width: 32rpx; height: 32rpx; opacity: 0.6; flex-shrink: 0; }
.profile-edit-text { flex: 1; font-size: 26rpx; color: #9ca3af; }
.profile-edit-arrow { font-size: 36rpx; color: rgba(255,255,255,0.3); font-weight: 300; }
/* ===== 主内容区 ===== */
.main-content { padding: 0 0 0 0; }
@@ -163,19 +200,6 @@
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; }
/* 已解锁区块:仅顶部弱对比图标,无标题字 */
.unlocked-card { padding-top: 28rpx; }
.unlocked-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 8rpx 16rpx 8rpx;
}
.unlocked-section-icon {
width: 40rpx;
height: 40rpx;
opacity: 0.92;
}
.recent-index { font-size: 28rpx; color: #6B7280; font-family: monospace; }
.recent-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-link { font-size: 24rpx; color: #4FD1C5; font-weight: 500; flex-shrink: 0; }
@@ -183,25 +207,6 @@
.recent-empty-text { font-size: 28rpx; color: #6B7280; display: block; margin-bottom: 24rpx; }
.recent-empty-btn { font-size: 28rpx; color: #4FD1C5; }
/* 已解锁章节列表底部倒三角展开(与首页「最新新增」一致) */
.unlocked-expand-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 8rpx 0 8rpx;
margin-top: 8rpx;
}
.unlocked-expand-hint-hover {
opacity: 0.65;
}
.unlocked-expand-triangle {
width: 0;
height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(79, 209, 197, 0.85);
}
/* 菜单 */
.menu-card { padding: 0; margin-bottom: 48rpx; overflow: hidden; }
.menu-item {

View File

@@ -13,6 +13,9 @@ 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']
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
Page({
data: {
statusBarHeight: 44,
@@ -41,9 +44,19 @@ Page({
loading: true,
showPrivacyModal: false,
nicknameInputFocus: false,
/** 首次完善:分 3 步full=1 或未达标资料时为单页 */
wizardMode: false,
wizardStep: 1,
totalWizardSteps: 3,
/** 头像区展示:含 MBTI 默认图 */
avatarPreviewUrl: '',
},
onLoad(options) {
this._wizardRouteOpts = {
full: options?.full === '1',
wizardOff: options?.wizard === '0',
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
fromVip: options?.from === 'vip',
@@ -72,6 +85,9 @@ Page({
app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }),
app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }),
])
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
this.setData({ isVip: vipRes?.data?.isVip || false })
const res = profileRes
if (res?.success && res.data) {
@@ -98,6 +114,8 @@ Page({
projectIntro: v('projectIntro'),
loading: false,
})
this._applyWizardModeFromProfile(d)
this._syncAvatarPreview()
setTimeout(() => this.generateShareCard(), 200)
} else {
this.setData({ loading: false })
@@ -109,6 +127,49 @@ Page({
goBack() { getApp().goBackOrToHome() },
/**
* 是否走三步向导:资料未「手机号+昵称」齐全且未标记完成,且非 full=1、非 VIP 开通页强制单页。
* 老用户已齐全则自动写 DONE避免重复向导。
*/
_applyWizardModeFromProfile(d) {
const ro = this._wizardRouteOpts || {}
const forceFull = ro.full === true
const forceNoWizard = ro.wizardOff === true
const phoneNum = String(d.phone || '').replace(/\D/g, '')
const phoneOk = /^1[3-9]\d{9}$/.test(phoneNum)
const nickOk = !!(d.nickname && String(d.nickname).trim())
if (phoneOk && nickOk && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
}
const wizardMode = !forceFull && !forceNoWizard && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)
this.setData({ wizardMode, wizardStep: 1 })
},
onWizardPrev() {
if (this.data.wizardStep > 1) {
this.setData({ wizardStep: this.data.wizardStep - 1 })
}
},
onWizardNext() {
if (this.data.saving) return
const { wizardMode, wizardStep } = this.data
if (!wizardMode) return
if (wizardStep === 1) {
if (!(this.data.nickname || '').trim()) {
wx.showToast({ title: '请填写昵称', icon: 'none' })
return
}
this.setData({ wizardStep: 2 })
return
}
if (wizardStep === 2) {
this.setData({ wizardStep: 3 })
return
}
this._doSaveProfile({ wizardComplete: true })
},
onNicknameAreaTouch() {
if (typeof wx.requirePrivacyAuthorize !== 'function') return
wx.requirePrivacyAuthorize({
@@ -312,7 +373,22 @@ Page({
onMbtiPickerChange(e) {
const i = parseInt(e.detail.value, 10)
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }, () => this._syncAvatarPreview())
},
_syncAvatarPreview() {
try {
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const url = resolveAvatarWithMbti(
this.data.avatar,
this.data.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({ avatarPreviewUrl: url || '' })
} catch (_) {
this.setData({ avatarPreviewUrl: (this.data.avatar || '').trim() })
}
},
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
@@ -345,7 +421,7 @@ Page({
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
this.setData({ avatar: avatarUrl })
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
const avatarToSave = toAvatarPath(avatarUrl)
await app.request({
url: '/api/miniprogram/user/profile',
@@ -365,7 +441,11 @@ Page({
}
},
async saveProfile() {
saveProfile() {
this._doSaveProfile({ wizardComplete: false })
},
async _doSaveProfile({ wizardComplete }) {
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
@@ -373,7 +453,6 @@ Page({
}
const s = (v) => (v || '').toString().trim()
const isVip = this.data.isVip
// 手机号必填,格式校验(支持带空格/连字符输入)
const phoneRaw = s(this.data.phone)
if (!phoneRaw) {
wx.showToast({ title: '请输入手机号', icon: 'none' })
@@ -428,8 +507,17 @@ Page({
if (app.globalData.userInfo) {
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
if (payload.phone) app.globalData.userInfo.phone = payload.phone
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
if (wizardComplete) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
this.setData({ wizardMode: false, saving: false })
setTimeout(() => {
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${userId}` })
}, 400)
return
}
setTimeout(() => getApp().goBackOrToHome(), 800)
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })

View File

@@ -1,158 +1,311 @@
<!-- 资料编辑 - comprehensive_profile_editor_v1_1 | input/textarea 用 view 包裹,配色 enhanced -->
<!-- 资料编辑:单页 full=1首次未完善走三步向导保存后跳转超级个体名片 -->
<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="#5EEAD4" customClass="back-icon"></icon></view>
<text class="nav-title">编辑资料</text>
<text class="nav-title">{{wizardMode ? '分步档案(' + wizardStep + '/3' : '编辑资料'}}</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 温馨提示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">{{fromVip ? '恭喜成为VIP完善资料后即可使用找伙伴、提现等功能手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}}</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器(用微信头像/从相册选择/拍照) -->
<view class="avatar-section">
<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>
</button>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<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>
<!-- —— 三步向导(首次) —— -->
<block wx:if="{{wizardMode}}">
<view class="wizard-bar">
<view class="wizard-step {{wizardStep >= 1 ? 'wizard-step-on' : ''}}">1</view>
<view class="wizard-line {{wizardStep >= 2 ? 'wizard-line-on' : ''}}"></view>
<view class="wizard-step {{wizardStep >= 2 ? 'wizard-step-on' : ''}}">2</view>
<view class="wizard-line {{wizardStep >= 3 ? 'wizard-line-on' : ''}}"></view>
<view class="wizard-step {{wizardStep >= 3 ? 'wizard-step-on' : ''}}">3</view>
</view>
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
<text class="wizard-sub">分步完善,保存后将进入「我的超级个体名片」,可转发分享</text>
<block wx:if="{{wizardStep === 1}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 1 步:先设置对外展示的头像与昵称</text>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" 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>
</button>
</view>
<view class="section section-wizard">
<view class="form-row">
<text class="form-label">昵称<text class="required-mark">*</text></text>
<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>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/电商" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如年GMV 5000万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人/联合创始人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
</block>
<!-- 核心联系方式 -->
<view class="section">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<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>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入微信号" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<block wx:if="{{wizardStep === 2}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 2 步:补充职业画像(可后补,尽量填写便于匹配)</text>
</view>
<view class="section section-wizard">
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:福建厦门" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/企业服务" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:年 GMV 5000 万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人 / 业务负责人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:私域运营、投融资对接" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
</block>
<!-- 个人故事(仅 VIP 展示) -->
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月做的是什么</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如2021年主导电商大促单月GMV突破500W..." value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如帮助3个素人打造个人IP每月稳定变现5万+" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如:辞去大厂工作开始做自媒体..." value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<block wx:if="{{wizardStep === 3}}">
<view class="tip-card tip-card-wizard">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">第 3 步:手机号必填;微信号建议填写,便于链接与提现</text>
</view>
<view class="section section-wizard">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号<text class="required-mark">*</text></text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="11 位手机号" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="选填" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="选填" value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
</block>
<!-- 互助需求VIP 或 资源对接已填写时展示) -->
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
<view class="wizard-actions">
<view class="wizard-btn-secondary" wx:if="{{wizardStep > 1}}" bindtap="onWizardPrev">上一步</view>
<view class="wizard-btn-primary {{wizardStep === 1 ? 'wizard-btn-full' : ''}}" bindtap="onWizardNext">
{{wizardStep === 3 ? (saving ? '保存中...' : '保存并查看名片') : '下一步'}}
</view>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频脚本、账号冷启动、私域转化" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:寻找供应链资源、线下活动合作" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="bottom-space"></view>
</block>
<!-- 项目介绍(仅 VIP 展示) -->
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
<!-- —— 单页完整编辑(已完善过或 full=1 —— -->
<block wx:else>
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
<text class="tip-text">{{fromVip ? '恭喜成为 VIP。补全资料后找伙伴、提现与群对接更顺畅手机号为必填' : '手机号为必填;建议填写微信号,便于提现核对与对方联系。'}}</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="详细介绍您的项目,让潜在伙伴更好地了解您..." value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '保存'}}
</view>
<view class="bottom-space"></view>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" 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>
</button>
</view>
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<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="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/电商" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如年GMV 5000万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人/联合创始人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row" wx:if="{{isVip}}">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
<view class="section">
<view class="section-title">
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>核心联系方式</text>
</view>
<view class="form-row">
<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>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入微信号" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月做的是什么</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如2021年主导电商大促单月GMV突破500W..." value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如帮助3个素人打造个人IP每月稳定变现5万+" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如:辞去大厂工作开始做自媒体..." value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
<view class="section-title">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>互助需求</text>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频脚本、账号冷启动、私域转化" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:寻找供应链资源、线下活动合作" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<view class="section" wx:if="{{isVip}}">
<view class="section-title">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text>项目介绍</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="详细介绍您的项目,让潜在伙伴更好地了解您..." value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '保存'}}
</view>
<view class="bottom-space"></view>
</block>
</scroll-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>

View File

@@ -333,3 +333,90 @@
.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; }
/* —— 三步向导 —— */
.wizard-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-bottom: 20rpx;
padding: 0 16rpx;
}
.wizard-step {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
color: #64748b;
background: rgba(148, 163, 184, 0.15);
border: 2rpx solid rgba(148, 163, 184, 0.25);
}
.wizard-step-on {
color: #042f2e;
background: linear-gradient(135deg, #5eead4, #2dd4bf);
border-color: transparent;
}
.wizard-line {
width: 48rpx;
height: 4rpx;
border-radius: 4rpx;
background: rgba(148, 163, 184, 0.2);
}
.wizard-line-on {
background: linear-gradient(90deg, #5eead4, #2dd4bf);
}
.wizard-sub {
display: block;
font-size: 24rpx;
color: #94a3b8;
line-height: 1.5;
text-align: center;
margin-bottom: 32rpx;
padding: 0 12rpx;
}
.tip-card-wizard {
margin-bottom: 32rpx;
}
.section-wizard {
border-top: none;
padding-top: 0;
margin-bottom: 32rpx;
}
.wizard-actions {
display: flex;
flex-direction: row;
gap: 24rpx;
margin-top: 24rpx;
margin-bottom: 24rpx;
}
.wizard-btn-secondary {
flex: 1;
text-align: center;
padding: 28rpx 24rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 600;
color: #94a3b8;
border: 2rpx solid rgba(148, 163, 184, 0.35);
background: transparent;
}
.wizard-btn-primary {
flex: 1;
text-align: center;
padding: 28rpx 24rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 700;
color: #042f2e;
background: linear-gradient(135deg, #5eead4, #2dd4bf);
box-shadow: 0 8rpx 24rpx rgba(45, 212, 191, 0.25);
}
.wizard-btn-full {
flex: none;
width: 100%;
}

View File

@@ -3,6 +3,8 @@
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
Page({
data: {
@@ -30,15 +32,27 @@ Page({
}
this.setData({ loading: true })
try {
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
const e = (v) => (v == null || v === undefined ? '' : (String(v).trim() === '' || String(v).trim() === '未填写' ? '' : String(v).trim()))
const phone = d.phone || ''
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
const av = d.avatar
const safeAv = isSafeImageSrc(av) ? String(av).trim() : ''
const displayAv = resolveAvatarWithMbti(
safeAv,
d.mbti,
app.globalData.mbtiAvatarsMap || {},
app.globalData.baseUrl || ''
)
this.setData({
profile: {
...d,
avatar: displayAv,
industry: e(d.industry),
position: e(d.position),
businessScale: e(d.businessScale || d.business_scale),

View File

@@ -1,13 +1,92 @@
/**
* Soul创业实验 - 订单页
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等)
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
const PAID_STATUSES = new Set(['paid', 'completed', 'success'])
function parseOrderTimeMs(o) {
const raw = o.created_at || o.createdAt || o.pay_time || 0
const t = new Date(raw).getTime()
return Number.isFinite(t) ? t : 0
}
function formatShortDate(ms) {
if (!ms) return '--'
const d = new Date(ms)
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
return `${m}-${day}`
}
function midForSection(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return row?.mid ?? row?.MID ?? 0
}
function classifyNav(productType, productId, mid) {
const pt = String(productType || '').toLowerCase()
if (pt === 'section' && productId) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
}
if (pt === 'fullbook') {
return { kind: 'switchTab', path: '/pages/chapters/chapters', label: '去目录' }
}
if (pt === 'vip') {
return { kind: 'page', path: '/pages/vip/vip', label: '会员中心' }
}
if (pt === 'match') {
return { kind: 'switchTab', path: '/pages/match/match', label: '找伙伴' }
}
if (pt === 'balance_recharge') {
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
}
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
}
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
}
return { kind: 'none', label: '--' }
}
function mapApiOrderToRow(item, bookFlat) {
const status = String(item.status || '').toLowerCase()
if (!PAID_STATUSES.has(status)) return null
const pt = String(item.product_type || '').toLowerCase()
const productId = String(item.product_id || item.section_id || '').trim()
let mid = Number(item.section_mid ?? item.mid ?? item.MID ?? 0) || 0
if (pt === 'section' && productId && !mid) mid = midForSection(productId, bookFlat)
const titleRaw = cleanSingleLineField(item.product_name || '')
const title =
titleRaw ||
(pt === 'balance_recharge' ? '余额充值' : productId ? `订单 ${productId}` : '消费记录')
const amt = Number(item.amount)
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
const t = parseOrderTimeMs(item)
const nav = classifyNav(pt, productId, mid)
return {
rowKey: String(item.order_sn || item.id || `o_${t}`),
title,
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
actionLabel: nav.label,
nav,
_sortMs: t
}
}
Page({
data: {
statusBarHeight: 44,
orders: [],
loading: true
loading: true,
allRows: [],
displayRows: [],
historyExpanded: false
},
onLoad() {
@@ -16,63 +95,95 @@ Page({
this.loadOrders()
},
onShow() {
if (!this._purchasesFirstOnShowSkipped) {
this._purchasesFirstOnShowSkipped = true
return
}
if (app.globalData.isLoggedIn) this.loadOrders()
},
applyDisplay(expanded) {
const all = this.data.allRows || []
const display = expanded || all.length <= 5 ? all : all.slice(0, 5)
this.setData({ displayRows: display, historyExpanded: !!expanded })
},
expandHistory() {
if (this.data.historyExpanded) return
this.applyDisplay(true)
},
async loadOrders() {
this.setData({ loading: true })
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const userId = app.globalData.userInfo?.id
try {
const userId = app.globalData.userInfo?.id
if (userId) {
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
if (res && res.success && res.data) {
const raw = (res.data || []).map(item => ({
id: item.id || item.order_sn,
sectionId: item.product_id || item.section_id,
sectionMid: item.section_mid ?? item.mid ?? 0,
title: item.product_name || `章节 ${item.product_id || ''}`,
amount: item.amount || 0,
status: item.status || 'completed',
createTime: item.created_at ? new Date(item.created_at).toLocaleDateString() : '--',
_sortMs: new Date(item.created_at || item.pay_time || 0).getTime() || 0
}))
raw.sort((a, b) => b._sortMs - a._sortMs)
const orders = raw.map(({ _sortMs, ...rest }) => rest)
this.setData({ orders })
const res = await app.request({
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (res && res.success && Array.isArray(res.data)) {
const rows = res.data
.map((item) => mapApiOrderToRow(item, bookFlat))
.filter(Boolean)
.sort((a, b) => b._sortMs - a._sortMs)
.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
return
}
}
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
sectionMid: 0,
title: `章节 ${id}`,
amount: 1,
status: 'completed',
createTime: new Date(Date.now() - index * 86400000).toLocaleDateString()
}))
this.setData({ orders })
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const rows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' }
}
})
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
} catch (e) {
console.error('加载订单失败:', e)
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
this.setData({
orders: purchasedSections.map((id, i) => ({
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
}))
})
} finally {
this.setData({ loading: false })
this.setData({ allRows: [], loading: false })
this.applyDisplay(false)
}
},
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
if (!id) return
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
onOrderRowTap(e) {
const index = e.currentTarget.dataset.index
const row = (this.data.displayRows || [])[index]
if (!row || !row.nav) return
const { nav } = row
if (nav.kind === 'read' && nav.id) {
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
return
}
if (nav.kind === 'page' && nav.path) {
wx.navigateTo({ url: nav.path })
return
}
if (nav.kind === 'switchTab' && nav.path) {
wx.switchTab({ url: nav.path })
}
},
goBack() { getApp().goBackOrToHome() },
goBack() {
getApp().goBackOrToHome()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()

View File

@@ -14,20 +14,37 @@
<view class="skeleton"></view>
</view>
<view class="orders-list" wx:elif="{{orders.length > 0}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
<view class="order-info">
<view class="order-title-row">
<text class="order-unlock-icon">🔓</text>
<text class="order-title">{{item.title}}</text>
<view class="order-history-card" wx:elif="{{displayRows.length > 0}}">
<view class="order-history-head">
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="oh-list">
<view
class="oh-row"
wx:for="{{displayRows}}"
wx:key="rowKey"
bindtap="onOrderRowTap"
data-index="{{index}}"
>
<view class="oh-left">
<text class="oh-index">{{index + 1}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
</view>
</view>
<text class="order-time">{{item.createTime}}</text>
</view>
<view class="order-right">
<text class="order-amount">¥{{item.amount}}</text>
<text class="order-status">已完成</text>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
</view>
<view
class="oh-expand"
wx:if="{{allRows.length > 5 && !historyExpanded}}"
bindtap="expandHistory"
hover-class="oh-expand-hover"
hover-stay-time="80"
>
<view class="oh-triangle"></view>
</view>
</view>
<view class="empty" wx:else>

View File

@@ -7,17 +7,29 @@
.loading { display: flex; flex-direction: column; gap: 24rpx; }
.skeleton { height: 120rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton 1.5s ease-in-out infinite; border-radius: 24rpx; }
@keyframes skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.orders-list { display: flex; flex-direction: column; gap: 16rpx; }
.order-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; background: #1c1c1e; border-radius: 24rpx; }
.order-item:active { opacity: 0.92; }
.order-info { flex: 1; min-width: 0; }
.order-title-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 8rpx; }
.order-unlock-icon { font-size: 26rpx; line-height: 1.35; opacity: 0.55; flex-shrink: 0; }
.order-title { font-size: 28rpx; color: #fff; flex: 1; min-width: 0; }
.order-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.order-right { text-align: right; }
.order-amount { font-size: 28rpx; font-weight: 600; color: #00CED1; display: block; margin-bottom: 4rpx; }
.order-status { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
.oh-row {
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.oh-row:active { opacity: 0.92; }
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }
.oh-expand { display: flex; justify-content: center; align-items: center; padding: 8rpx 0 8rpx; margin-top: 8rpx; }
.oh-expand-hover { opacity: 0.65; }
.oh-triangle {
width: 0; height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(0, 206, 209, 0.85);
}
.empty { display: flex; flex-direction: column; align-items: center; padding: 96rpx; }
.empty-icon { font-size: 96rpx; margin-bottom: 24rpx; opacity: 0.5; }
.empty-text { font-size: 28rpx; color: rgba(255,255,255,0.4); }

View File

@@ -17,8 +17,8 @@ const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const app = getApp()
@@ -93,7 +93,7 @@ Page({
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 0, // 来自 app.getTotalSections() 或 book/parts
totalSections: 62,
// 弹窗
showShareModal: false,
@@ -116,21 +116,69 @@ Page({
// 余额(用于余额支付)
walletBalance: 0,
// 未解锁时显示的预览比例来自文章详情用于付费墙「已阅读X%」)
previewPercent: 20,
// 审核模式:隐藏购买按钮
auditMode: false,
// 分润比例(来自 config.shareRate用于分享提示文案
shareRate: 90,
// 好友从代付分享进入:待自动领取的 requestSn
pendingGiftRequestSn: '',
// 朋友圈单页模式scene 1154 / systemInfo.mode无法登录与支付仅引导「前往小程序」
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
},
/**
* 是否处于朋友圈等「单页预览」环境。
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
* 命中时同步 app.globalData.isSinglePageMode保证 ensureFullAppForAuth 与页内 wx:if 一致。
*/
_detectReadSinglePage() {
try {
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const enter = typeof wx.getEnterOptionsSync === 'function' ? wx.getEnterOptionsSync() : null
if (enter && Number(enter.scene) === 1154) {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const win = typeof wx.getWindowInfo === 'function' ? wx.getWindowInfo() : null
if (win && win.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
try {
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') {
app.globalData.isSinglePageMode = true
}
} catch (e) {}
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
const sp = this._detectReadSinglePage()
this.setData({
auditMode: app.globalData.auditMode || false,
readSinglePageMode: sp,
...(sp ? {} : { momentsPaywallExpanded: false }),
})
},
async onLoad(options) {
@@ -194,7 +242,8 @@ Page({
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || '',
totalSections: app.getTotalSections()
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -205,12 +254,9 @@ Page({
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate,
totalSections: app.getTotalSections()
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 统一:先拉章节数据,用 isFree/price===0 判断免费
@@ -245,9 +291,10 @@ Page({
}
}
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
} else {
app.touchRecentSection(id)
}
// 5. 导航:文章详情已带 prev/next
@@ -380,15 +427,13 @@ Page({
chapterTitle: res.chapterTitle || ''
}
if (res.mid) updates.sectionMid = res.mid
if (res.previewPercent != null && res.previewPercent >= 1 && res.previewPercent <= 100) {
updates.previewPercent = res.previewPercent
}
this.setData(updates)
// 写入本地缓存(存 displayContent供离线/重试降级使用)
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
app.touchRecentSection(id)
}
} catch (e) {
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
@@ -407,6 +452,7 @@ Page({
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
})
app.touchRecentSection(id)
console.log('[Read] 从本地缓存加载成功')
return
}
@@ -683,13 +729,71 @@ Page({
})
},
// 存客宝留资:统一 soulBridge.submitCkbLead与会员详情点头像同链路
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
async _doMentionAddFriend(targetUserId, targetNickname) {
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
source: 'article_mention'
})
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const myUserId = app.globalData.userInfo.id
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 || 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 || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'article_mention'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
// 分享弹窗
@@ -740,11 +844,7 @@ Page({
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '朋友圈单页',
content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。',
showCancel: false
})
this.onUnlockTapInSinglePage()
return
}
} catch (e) {}
@@ -798,8 +898,24 @@ Page({
}
const payParams = payRes.data.payParams
const orderSn = payRes.data.orderSn
await soulBridge.requestWxJsapiPayment(payParams)
await soulBridge.syncOrderStatusQuery(app, orderSn)
await new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType || 'RSA',
paySign: payParams.paySign,
success: resolve,
fail: reject
})
})
// 3) 主动同步(与其他支付流程一致)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
} catch (e) {}
}
wx.showToast({ title: '支付成功', icon: 'success' })
this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false })
@@ -817,7 +933,8 @@ Page({
// 复制链接
copyLink() {
const referralCode = app.getMyReferralCode() || ''
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || ''
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
wx.setClipboardData({
@@ -833,10 +950,9 @@ Page({
copyShareText() {
const { section } = this.data
const total = app.getTotalSections()
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
${total}个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
62个真实商业案例每个都是从0到1的实战经验。私域运营、资源整合、商业变现干货满满。
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
@@ -872,17 +988,9 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
return { title, path }
},
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户
onShareTimelineTap() {
wx.showToast({
title: '请点击右上角「...」→ 分享到朋友圈',
icon: 'none',
duration: 2500
})
},
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
// 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
@@ -901,7 +1009,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -928,21 +1036,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
try {
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '请前往完整小程序',
content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。',
showCancel: false,
confirmText: '我知道了',
})
return
}
} catch (e) {
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
// 单页模式无法弹登录组件:页内已说明「前往小程序」,不再弹 Modal
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
try {
this.setData({ showLoginModal: true })
@@ -1026,6 +1123,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
console.log('[Pay] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1045,6 +1146,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
this.onUnlockTapInSinglePage()
return
}
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
@@ -1063,6 +1168,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
// 处理支付 - 调用真实微信支付接口
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
try {
wx.hideLoading()
} catch (e) {}
this.onUnlockTapInSinglePage()
return
}
const userInfo = app.globalData.userInfo
if (userInfo?.id) {
@@ -1072,10 +1185,10 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像昵称',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '支付订单会关联你的对外展示信息,请先设置头像昵称,避免账单与对方看到默认占位。',
confirmText: '去设置',
cancelText: '关闭',
success: resolve
})
})
@@ -1132,7 +1245,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
// 0. 尝试余额支付(若余额足够)
const userId = app.globalData.userInfo?.id
const referralCode = soulBridge.getReferralCodeForPay(app)
const referralCode = wx.getStorageSync('referral_code') || ''
if (userId) {
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
@@ -1196,9 +1309,14 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
let paymentData = null
try {
// 获取章节完整名称用于支付描述
const sectionTitle = this.data.section?.title || sectionId
const description = soulBridge.buildSectionPayDescription(type, sectionId, sectionTitle)
const referralCode = soulBridge.getReferralCodeForPay(app)
const description = type === 'fullbook'
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -1229,7 +1347,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
@@ -1250,11 +1368,18 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
await soulBridge.requestWxJsapiPayment(paymentData)
await this.callWechatPay(paymentData)
// 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题)
const orderSn = paymentData._orderSn || paymentData.orderSn
await soulBridge.syncOrderStatusQuery(app, orderSn)
if (orderSn) console.log('[Pay] 已主动同步订单状态:', orderSn)
if (orderSn) {
try {
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
console.log('[Pay] 已主动同步订单状态:', orderSn)
} catch (e) {
console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e)
}
}
// 5. 【标准流程】刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
@@ -1342,6 +1467,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
checkAndExecute('after_pay', this)
} catch (e) {
wx.hideLoading()
@@ -1389,6 +1515,21 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
}
},
// 调用微信支付
callWechatPay(paymentData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: resolve,
fail: reject
})
})
},
// 跳转到上一篇
goToPrev() {
if (this.data.prevSection) {
@@ -1412,6 +1553,25 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
wx.navigateTo({ url: '/pages/referral/referral' })
},
/** 海报 canvas 在弹层渲染后偶现取不到 node多次重试 */
async _queryPosterCanvasNode(maxTry = 10, delayMs = 100) {
for (let i = 0; i < maxTry; i++) {
const node = await new Promise((resolve) => {
wx.createSelectorQuery()
.in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (res && res[0] && res[0].node) resolve(res[0])
else resolve(null)
})
})
if (node) return node
await new Promise((r) => setTimeout(r, delayMs))
}
return null
},
// 生成海报Canvas 2D API
async generatePoster() {
wx.showLoading({ title: '生成中...' })
@@ -1422,6 +1582,7 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
else setTimeout(resolve, 50)
})
await new Promise((r) => setTimeout(r, 120))
try {
const { section, contentParagraphs, sectionId, sectionMid } = this.data
@@ -1439,18 +1600,12 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
} catch (_) {}
const canvasNode = await new Promise((resolve, reject) => {
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (res && res[0] && res[0].node) resolve(res[0])
else reject(new Error('canvas node not found'))
})
})
const canvasNode = await this._queryPosterCanvasNode()
if (!canvasNode) {
throw new Error('canvas node not found')
}
const canvas = canvasNode.node
const ctx = canvas.getContext('2d')
let dpr = 2
try {
if (typeof wx.getWindowInfo === 'function') {
@@ -1461,73 +1616,100 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
} catch (_) {
dpr = 2
}
const width = 300
const height = 450
canvas.width = width * dpr
canvas.height = height * dpr
// 布局尺寸:优先用节点测量;为 0 时回退 300×450避免真机 query 过早得到 0 导致空白)
const layoutW = (canvasNode.width && canvasNode.width > 1) ? Math.round(canvasNode.width) : 300
const layoutH = (canvasNode.height && canvasNode.height > 1) ? Math.round(canvasNode.height) : 450
canvas.width = Math.max(1, Math.floor(layoutW * dpr))
canvas.height = Math.max(1, Math.floor(layoutH * dpr))
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d not supported')
ctx.scale(dpr, dpr)
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, width, height)
const paintPoster = async () => {
const w = layoutW
const h = layoutH
const grd = ctx.createLinearGradient(0, 0, 0, h)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.fillStyle = grd
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, width, 4)
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, w, 4)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('📚 卡若创业派对', 20, 35)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('卡若创业派对', 20, 35)
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, width - 40)
let y = 70
titleLines.forEach(line => { ctx.fillText(line, 20, y); y += 26 })
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText2d(ctx, title, w - 40)
let y = 70
titleLines.forEach((line) => { ctx.fillText(line, 20, y); y += 26 })
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(w - 20, y + 10)
ctx.stroke()
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText2d(ctx, summary, width - 40)
summaryLines.slice(0, 6).forEach(line => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, height - 100, width, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, width - 85, height - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, width, height)
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
let paras = Array.isArray(contentParagraphs) ? contentParagraphs.filter(Boolean) : []
if (!paras.length && this.data.content) {
const plain = String(this.data.content)
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (plain) paras = [plain.slice(0, 400)]
}
const rawSummary = paras.slice(0, 3).join(' ').trim() || '卡若创业派对 · 真实商业故事'
const summary = rawSummary.length > 160 ? rawSummary.slice(0, 157) + '...' : rawSummary
const summaryLines = this.wrapText2d(ctx, summary, w - 40)
summaryLines.slice(0, 6).forEach((line) => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, h - 100, w, 100)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, h - 60)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, h - 38)
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, w - 85, h - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, w, h)
}
} else {
this.drawQRPlaceholder2d(ctx, w, h)
}
}
if (typeof canvas.requestAnimationFrame === 'function') {
await new Promise((resolve, reject) => {
canvas.requestAnimationFrame(() => {
paintPoster().then(resolve).catch(reject)
})
})
} else {
this.drawQRPlaceholder2d(ctx, width, height)
await paintPoster()
}
wx.hideLoading()
@@ -1635,13 +1817,11 @@ ${total}个真实商业案例每个都是从0到1的实战经验。私域运
try {
const config = await accessManager.fetchLatestConfig()
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
this.setData({
sectionPrice: config.prices?.section ?? 1,
fullBookPrice: config.prices?.fullbook ?? 9.9,
shareRate
fullBookPrice: config.prices?.fullbook ?? 9.9
})
// 重新拉取章节,用 isFree/price 判断免费
const chapterRes = await app.request({
url: this._getChapterUrl({}),

View File

@@ -89,21 +89,21 @@
</view>
</view>
<!-- 分享操作区 -->
<!-- 分享区:仅好友/海报/代付;完整小程序发圈见右下角悬浮钮 -->
<view class="action-section">
<view class="action-row-inline">
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
<icon name="megaphone" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享到朋友圈</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<button plain class="action-share-native action-tile-unified" open-type="share" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="share" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">分享给好友</text>
</button>
<button plain class="action-share-native action-tile-unified" bindtap="generatePoster" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
</button>
<button plain class="action-share-native action-tile-unified" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}" hover-class="action-share-native-hover" hover-stop-propagation>
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">代付分享</text>
</view>
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
@@ -122,23 +122,36 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
<view class="paywall">
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并支付 ¥1 后阅读全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
</block>
</view>
<!-- 章节导航 -->
@@ -182,39 +195,47 @@
<view class="fade-mask"></view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall">
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<!-- 购买选项(审核模式隐藏) -->
<view class="purchase-options" wx:if="{{!auditMode}}">
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<!-- 解锁全书 - 只有购买超过3章才显示 -->
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice || 9.9}}</text>
<text class="btn-discount">省82%</text>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">解锁全文</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥1</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
</block>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
</block>
</view>
<!-- 章节导航 -->
@@ -270,9 +291,9 @@
</view>
</view>
<!-- 海报生成弹窗 -->
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content poster-modal" catchtap="stopPropagation">
<!-- 海报生成弹窗:居中 + z-index 高于右下角悬浮,避免「空白」错觉 -->
<view class="modal-overlay modal-overlay-center" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
<view class="modal-content modal-content-center poster-modal" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">生成海报</text>
<view class="modal-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
@@ -357,8 +378,12 @@
</view>
</view>
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
<!-- 单页预览(朋友圈):指向底栏「前往小程序」,字少;完整小程序仍保留发圈悬浮钮 -->
<view class="singlepage-launch-pointer" wx:if="{{readSinglePageMode && momentsPaywallExpanded}}" aria-hidden="true">
<view class="singlepage-launch-pointer__arrow"></view>
</view>
<view class="fab-share-moments" wx:if="{{!readSinglePageMode}}" bindtap="shareToMoments" hover-class="fab-share-moments-hover" aria-label="分享到朋友圈">
<icon name="share" size="44" color="#ffffff" customClass="fab-share-moments-icon"></icon>
</view>
</view>

View File

@@ -280,6 +280,36 @@
margin-bottom: 16rpx;
}
/* 朋友圈单页:与完整小程序同款购买行,留白略紧 */
.paywall--single-preview {
padding-top: 40rpx;
padding-bottom: 40rpx;
}
.paywall--single-preview .paywall-icon {
margin-bottom: 24rpx;
}
.paywall--single-preview .paywall-title {
margin-bottom: 28rpx;
}
.paywall-desc--moments-expanded {
margin-top: 28rpx !important;
margin-bottom: 0 !important;
font-size: 26rpx !important;
line-height: 1.45;
padding: 0 8rpx;
}
/* 朋友圈单页:未点解锁前的一行轻提示 */
.paywall-hint-compact {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.48);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.55;
padding: 0 16rpx;
}
.paywall-desc {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
@@ -360,6 +390,33 @@
margin-left: 8rpx;
}
.paywall-singlepage-note {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.45);
text-align: center;
line-height: 1.5;
}
/* 朋友圈单页付费墙底部:与正文文末「分享赚收益」文案一致 */
.paywall-share-earn-wrap {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
text-align: center;
}
.paywall-share-earn-wrap .share-tip-text {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.5);
line-height: 1.5;
}
.paywall-share-earn-sub {
margin-top: 12rpx !important;
display: block;
}
.paywall-tip {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
@@ -470,7 +527,14 @@
.action-row-inline {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
align-items: stretch;
gap: 12rpx;
}
/* 底部三按钮:同一底纹与描边(好友 / 海报 / 代付) */
.action-tile-unified {
background: rgba(255, 255, 255, 0.06) !important;
border: 2rpx solid rgba(0, 206, 209, 0.28) !important;
}
.action-btn-inline {
@@ -489,21 +553,38 @@
overflow: hidden;
}
.action-btn-inline::after {
/* 分享给好友:原生 button + open-type=share样式与 action-btn-inline 对齐 */
.action-share-native {
flex: 1 1 0;
min-width: 0;
min-height: 96rpx;
margin: 0;
padding: 24rpx 12rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
line-height: normal;
font-size: inherit;
box-sizing: border-box;
overflow: hidden;
}
.action-share-native::after {
border: none;
}
.btn-share-inline {
background: rgba(7, 193, 96, 0.15);
border: 2rpx solid rgba(7, 193, 96, 0.3);
button.action-share-native {
color: inherit;
}
.action-share-native-hover {
opacity: 0.85;
}
.btn-poster-inline {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
.action-btn-inline::after {
border: none;
}
.action-icon-small {
font-size: 28rpx;
flex-shrink: 0;
@@ -597,7 +678,8 @@
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
/* 高于右下角悬浮钮,避免弹层被盖住或 canvas 不可见 */
z-index: 10050;
}
.modal-overlay-center {
@@ -1201,6 +1283,9 @@
/* ===== 海报弹窗 ===== */
.poster-modal {
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
max-height: 85vh;
overflow-y: auto;
box-sizing: border-box;
}
.poster-preview {
@@ -1251,44 +1336,54 @@
display: block;
}
/* ===== 右下角悬浮分享按钮 ===== */
.fab-share {
/* ===== 右下角:分享到朋友圈(固定悬浮小圆钮,不在文末分享行) ===== */
.fab-share-moments {
position: fixed;
right: 32rpx;
width:70rpx!important;
bottom: calc(120rpx + env(safe-area-inset-bottom));
height: 70rpx;
border-radius: 60rpx;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%);
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.4);
padding: 0;
margin: 0;
border: none;
z-index: 9999;
display:flex;
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
z-index: 9980;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.fab-share::after {
border: none;
.fab-share-moments-hover {
opacity: 0.9;
}
.fab-share:active {
transform: scale(0.95);
box-shadow: 0 4rpx 20rpx rgba(0, 206, 209, 0.5);
.fab-share-moments:active {
transform: scale(0.94);
}
.fab-icon {
padding:16rpx;
width: 50rpx;
height: 50rpx;
display: block;
}
.fab-share-icon {
.fab-share-moments-icon {
font-size: 44rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
}
/* 朋友圈单页:点「购买本章」后,箭头指向底栏「前往小程序」(字少,仅符号) */
.singlepage-launch-pointer {
position: fixed;
right: 48rpx;
bottom: calc(168rpx + env(safe-area-inset-bottom));
z-index: 99985;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.singlepage-launch-pointer__arrow {
font-size: 56rpx;
line-height: 1;
color: #00CED1;
text-shadow: 0 0 20rpx rgba(0, 206, 209, 0.55);
transform: rotate(0deg);
animation: singlepage-launch-pulse 1.25s ease-in-out infinite;
}
@keyframes singlepage-launch-pulse {
0%, 100% { opacity: 0.75; transform: translate(0, 0) scale(1); }
50% { opacity: 1; transform: translate(8rpx, 10rpx) scale(1.06); }
}

View File

@@ -0,0 +1,178 @@
/**
* 阅读记录:最近阅读 + 已读章节(与「我的」数据源一致)
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
function titleFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${sectionId}`
}
function midFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return row?.mid ?? row?.MID ?? 0
}
function mergeRecentFromLocal(apiList) {
const normalized = Array.isArray(apiList)
? apiList.map((item) => ({
id: item.id,
mid: item.mid,
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
}))
: []
if (normalized.length > 0) return normalized
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
return Object.keys(progressData)
.map((id) => ({
id,
ts: progressData[id]?.lastOpenAt || progressData[id]?.last_open_at || 0
}))
.filter((e) => e.id)
.sort((a, b) => b.ts - a.ts)
.slice(0, 20)
.map((e) => ({
id: e.id,
mid: midFromBookData(e.id, bookFlat),
title: titleFromBookData(e.id, bookFlat)
}))
} catch (e) {
return []
}
}
Page({
data: {
statusBarHeight: 44,
isLoggedIn: false,
focus: 'all',
recentList: [],
readAllList: [],
recentSectionTitle: '最近阅读',
readSectionTitle: '已读章节'
},
onLoad(options) {
const focus = (options.focus === 'recent' || options.focus === 'read') ? options.focus : 'all'
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
focus
})
this._applyMpUiTitles()
},
onShow() {
this.setData({ isLoggedIn: !!(app.globalData.isLoggedIn && app.globalData.userInfo?.id) })
this._applyMpUiTitles()
if (this.data.isLoggedIn) this.loadData()
},
_applyMpUiTitles() {
const my = app.globalData.configCache?.mpConfig?.mpUi?.myPage || {}
this.setData({
recentSectionTitle: my.recentReadTitle || '最近阅读',
readSectionTitle: my.readStatLabel || '已读章节'
})
},
async _ensureBookFlat() {
let flat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
if (flat.length) return flat
try {
const r = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const list = r?.data
if (Array.isArray(list) && list.length) {
app.globalData.bookData = list
return list
}
} catch (_) {}
return []
},
async loadData() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
silent: true
})
const bookFlat = await this._ensureBookFlat()
let recent = []
let readIds = []
if (res?.success && res.data) {
const apiRecent = Array.isArray(res.data.recentChapters) ? res.data.recentChapters : []
recent = mergeRecentFromLocal(apiRecent)
readIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
} else {
recent = mergeRecentFromLocal([])
}
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const fromKeys = Object.keys(progressData).filter(Boolean)
const stored = wx.getStorageSync('readSectionIds')
const fromStored = Array.isArray(stored) ? stored.filter(Boolean) : []
const fromGlobal = Array.isArray(app.globalData.readSectionIds)
? app.globalData.readSectionIds.filter(Boolean)
: []
readIds = [...new Set([...readIds, ...fromGlobal, ...fromStored, ...fromKeys])]
} catch (_) {}
if (readIds.length === 0 && recent.length > 0) {
readIds = recent.map((r) => r.id)
}
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (e) {
console.warn('[reading-records]', e)
try {
const bookFlat = await this._ensureBookFlat()
let readIds = Array.isArray(app.globalData.readSectionIds) ? [...app.globalData.readSectionIds] : []
if (!readIds.length) {
try {
const stored = wx.getStorageSync('readSectionIds')
if (Array.isArray(stored)) readIds = [...stored]
} catch (_) {}
}
const recent = mergeRecentFromLocal([])
if (!readIds.length && recent.length) readIds = recent.map((r) => r.id)
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (_) {
this.setData({ recentList: [], readAllList: [] })
}
}
},
goRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
if (!id) return
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
goChapters() {
wx.switchTab({ url: '/pages/chapters/chapters' })
},
goLogin() {
wx.switchTab({ url: '/pages/my/my' })
},
goBack() {
getApp().goBackOrToHome()
}
})

View File

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

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.8)" 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" wx:if="{{isLoggedIn}}">
<view class="section" wx:if="{{focus === 'all' || focus === 'recent'}}">
<view class="section-head">
<text class="section-title">{{recentSectionTitle}}</text>
<text class="section-count" wx:if="{{recentList.length > 0}}">{{recentList.length}} 条</text>
</view>
<view class="list" wx:if="{{recentList.length > 0}}">
<view class="row" wx:for="{{recentList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无最近阅读</text>
<text class="empty-a" bindtap="goChapters">去目录逛逛</text>
</view>
</view>
<view class="section" wx:if="{{focus === 'all' || focus === 'read'}}">
<view class="section-head">
<text class="section-title">{{readSectionTitle}}</text>
<text class="section-count" wx:if="{{readAllList.length > 0}}">共 {{readAllList.length}} 章</text>
</view>
<view class="list" wx:if="{{readAllList.length > 0}}">
<view class="row" wx:for="{{readAllList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无已读记录</text>
<text class="empty-a" bindtap="goChapters">去目录选章阅读</text>
</view>
</view>
</view>
<view class="guest" wx:else>
<text class="guest-t">登录后查看阅读记录</text>
<view class="guest-btn" bindtap="goLogin">去登录</view>
</view>
</view>

View File

@@ -0,0 +1,25 @@
.page { min-height: 100vh; background: #000; padding-bottom: 48rpx; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.92); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; flex: 1; text-align: center; }
.nav-placeholder { width: 72rpx; }
.content { padding: 32rpx; }
.section { margin-bottom: 48rpx; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24rpx; }
.section-title { font-size: 32rpx; font-weight: 600; color: #e5e7eb; }
.section-count { font-size: 24rpx; color: #6b7280; }
.list { display: flex; flex-direction: column; gap: 16rpx; }
.row {
display: flex; align-items: center; gap: 20rpx;
padding: 24rpx; background: #1c1c1e; border-radius: 20rpx;
}
.row:active { opacity: 0.9; }
.row-idx { font-size: 26rpx; color: #6b7280; font-family: monospace; flex-shrink: 0; }
.row-title { flex: 1; min-width: 0; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-link { font-size: 24rpx; color: #00CED1; flex-shrink: 0; }
.empty { padding: 48rpx 24rpx; text-align: center; }
.empty-t { display: block; font-size: 28rpx; color: #6b7280; margin-bottom: 24rpx; }
.empty-a { font-size: 28rpx; color: #00CED1; }
.guest { padding: 120rpx 48rpx; text-align: center; }
.guest-t { display: block; font-size: 30rpx; color: #9ca3af; margin-bottom: 32rpx; }
.guest-btn { display: inline-block; padding: 20rpx 48rpx; background: #00CED1; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }

View File

@@ -35,7 +35,7 @@ Page({
minWithdrawAmount: 10, // 最低提现金额,从 referral/data 获取
bindingDays: 30, // 绑定期天数,从 referral/data 获取
userDiscount: 5, // 好友购买优惠%,从 referral/data 获取
hasWechatId: false, // 是否已绑定微信号(未绑定时需引导去设置)
hasWechatId: false, // 是否已绑定微信号(未绑定时提示去设置
// === 统计数据 ===
referralCount: 0, // 总推荐人数
@@ -46,6 +46,8 @@ Page({
// 绑定用户列表
showBindingList: true,
showShareSection: false,
showEarningsDetails: false,
activeTab: 'active',
activeBindings: [],
convertedBindings: [],
@@ -63,10 +65,13 @@ Page({
posterReferralLink: '',
posterNickname: '',
posterNicknameInitial: '',
posterCaseCount: 0 // 来自 app.getTotalSections()initData 后更新
/** 推广海报推荐人头像:优先微信头像,否则按 MBTI 用后台映射 */
posterDisplayAvatar: '',
posterCaseCount: 62
},
onLoad() {
onLoad(options) {
this._focusTarget = (options && options.focus) || ""
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.initData()
// 启用分享到朋友圈(需同时有 onShareAppMessage 和 onShareTimelinemenus 在 Android 支持iOS 为 Beta
@@ -174,7 +179,6 @@ Page({
minWithdrawAmount: minWithdrawAmount,
bindingDays: realData?.bindingDays ?? 30,
userDiscount: realData?.userDiscount ?? 5,
posterCaseCount: app.getTotalSections(),
// 统计
referralCount: realData?.referralCount || realData?.stats?.totalBindings || activeBindings.length + convertedBindings.length,
@@ -208,12 +212,37 @@ Page({
// 隐藏加载提示
wx.hideLoading()
this._applyFocusTarget()
} else {
// 未登录时也隐藏loading
this.setData({ isLoading: false })
}
},
toggleShareSection() {
this.setData({ showShareSection: !this.data.showShareSection })
},
toggleEarningsDetails() {
this.setData({ showEarningsDetails: !this.data.showEarningsDetails })
},
_applyFocusTarget() {
const t = this._focusTarget || ''
if (!t) return
if (t === 'bindings') {
this.setData({ showBindingList: true, activeTab: 'active', currentBindings: this.data.activeBindings })
setTimeout(() => wx.pageScrollTo({ selector: '#binding-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
if (t === 'earnings') {
setTimeout(() => wx.pageScrollTo({ selector: '#earnings-card', duration: 220 }), 80)
this._focusTarget = ''
return
}
},
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab
@@ -343,11 +372,20 @@ Page({
console.log('[Poster] 小程序码已保存到本地:', filePath)
try {
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
} catch (_) {}
const u = this.data.userInfo || app.globalData.userInfo || {}
const posterDisplayAvatar = app.resolveAvatarWithMbti
? app.resolveAvatarWithMbti(u.avatar, u.mbti)
: ''
this.setData({
posterQrSrc: filePath,
posterReferralLink: '', // 小程序版本不再使用 H5 链接
posterNickname: nickname,
posterNicknameInitial: (nickname || '用').charAt(0),
posterDisplayAvatar,
isGeneratingPoster: false
})
wx.hideLoading()
@@ -355,7 +393,7 @@ Page({
console.error('[Poster] 生成二维码失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '' })
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '', posterDisplayAvatar: '' })
}
},
@@ -546,10 +584,9 @@ Page({
// 分享到朋友圈 - 随机文案
shareToMoments() {
const total = app.getTotalSections()
// 10条随机文案基于书的内容
const shareTexts = [
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n${total}个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`🔥 在派对房里听到的真实故事比虚构的小说精彩100倍\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n62个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
`💡 今天终于明白:会赚钱的人,都在用"流量杠杆"\n\n抖音、Soul、飞书...同一套内容,撬动不同平台的流量。\n\n《卡若创业派对》里的实战方法,受用终身!\n\n#流量 #副业 #创业派对`,
@@ -600,7 +637,7 @@ Page({
}
// 任意金额可提现,不再设最低限额
// 未绑定微信号时引导去设置
// 未绑定微信号:说明提现到账核对所需
if (!hasWechatId) {
wx.showModal({
title: '请先绑定微信号',
@@ -656,31 +693,37 @@ Page({
method: 'POST',
data: { userId, amount }
})
wx.hideLoading()
wx.showModal({
title: '提现申请已提交 ✅',
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
showCancel: false,
confirmText: '知道了'
})
this.initData()
if (res.success) {
wx.showModal({
title: '提现申请已提交 ✅',
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
showCancel: false,
confirmText: '知道了'
})
// 刷新数据(此时待审核金额会增加,可提现金额会减少)
this.initData()
} else {
if (res.needBind || res.needBindWechat) {
wx.showModal({
title: res.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: res.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
} else {
wx.showToast({ title: res.message || res.error || '提现失败', icon: 'none', duration: 3000 })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Referral] 提现失败:', e)
const r = e.response || {}
if (r.needBind || r.needBindWechat) {
wx.showModal({
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
wx.showToast({ title: e.message || '提现失败,请重试', icon: 'none', duration: 3000 })
wx.showToast({ title: '提现失败,请重试', icon: 'none' })
}
},
@@ -856,9 +899,8 @@ Page({
onShareTimeline() {
const ref = this.data.referralCode || app.getMyReferralCode()
console.log('[Referral] 分享到朋友圈,推荐码:', ref)
const total = app.getTotalSections()
return {
title: `卡若创业派对 - ${total}个真实商业案例`,
title: `卡若创业派对 - 62个真实商业案例`,
query: ref ? `ref=${ref}` : ''
// 不设置 imageUrl使用小程序默认截图
}

View File

@@ -25,8 +25,7 @@
</view>
<!-- 收益卡片 - 对齐 Next.js -->
<view class="earnings-card">
<view class="earnings-bg"></view>
<view id="earnings-card" class="earnings-card">
<view class="earnings-main">
<view class="earnings-header">
<view class="earnings-left">
@@ -87,7 +86,7 @@
</view>
<!-- 绑定用户列表 -->
<view class="binding-card">
<view id="binding-card" class="binding-card">
<view class="binding-header" bindtap="toggleBindingList">
<view class="binding-title">
<image class="binding-icon-img" src="/assets/icons/users.svg" mode="aspectFit"></image>
@@ -161,9 +160,14 @@
</block>
</view>
<!-- 分享按钮 - 1:1 对齐 Next.js -->
<view class="share-section">
<view class="share-item" bindtap="generatePoster">
<!-- 分享按钮(可折叠,默认收起) -->
<view class="section-collapse-card">
<view class="section-collapse-header" bindtap="toggleShareSection">
<text class="section-collapse-title">分享推广</text>
<icon name="{{showShareSection ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="share-section" wx:if="{{showShareSection}}">
<view class="share-item" bindtap="generatePoster">
<view class="share-icon poster">
<image class="icon-share-btn" src="/assets/icons/image.svg" mode="aspectFit"></image>
</view>
@@ -196,13 +200,15 @@
<image class="share-arrow-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 收益明细 - 增强版 -->
<view class="earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="detail-header">
<text class="detail-title">收益明细</text>
<!-- 收益明细(可折叠,默认收起) -->
<view class="section-collapse-card earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
<view class="section-collapse-header" bindtap="toggleEarningsDetails">
<text class="section-collapse-title">收益明细</text>
<icon name="{{showEarningsDetails ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
</view>
<view class="detail-list">
<view class="detail-list" wx:if="{{showEarningsDetails}}">
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
<!-- 买家头像 -->
<view class="detail-avatar-wrap">
@@ -297,7 +303,8 @@
<!-- 推荐人 -->
<view class="poster-recommender">
<view class="poster-avatar">
<text class="poster-avatar-text">{{posterNicknameInitial}}</text>
<image wx:if="{{posterDisplayAvatar}}" class="poster-avatar-img" src="{{posterDisplayAvatar}}" mode="aspectFill" />
<text wx:else class="poster-avatar-text">{{posterNicknameInitial}}</text>
</view>
<text class="poster-recommender-text">{{posterNickname}} 推荐你来读</text>
</view>

View File

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

View File

@@ -34,7 +34,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
@@ -50,7 +50,7 @@ Page({
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
(app.globalData.appDisplayVersion || '1.7.2')
this.setData({ version: displayVersion })
this.loadBindingInfo()
},

View File

@@ -13,16 +13,16 @@ Page({
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ title: '轮流置顶', desc: '首页获客曝光位', icon: 'arrow-up' }
{ key: 'match', title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ key: 'party', title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ key: 'rank', title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ key: 'top', title: '轮流置顶', desc: '首页获客曝光位', icon: 'chevron-up' }
],
socialRights: [
{ title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
{ key: 'cases', title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ key: 'fullbook', title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ key: 'daily', title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ key: 'leads', title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
],
purchasing: false
},
@@ -86,7 +86,7 @@ Page({
return
}
}
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
// VIP 购买成功后再跳转资料:购买前不拦截
this.setData({ purchasing: true })
const amount = this.data.price
try {
@@ -167,9 +167,9 @@ Page({
// 超级个体购买后:弹窗提示,强制跳转资料编辑页
wx.hideLoading()
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请填写好资料',
confirmText: '去完善',
title: '补全 VIP 资料',
content: '补全资料后,找伙伴、提现与 VIP 群对接会更顺畅;手机号等为必填项。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?from=vip' })
@@ -183,6 +183,33 @@ Page({
goBack() { getApp().goBackOrToHome() },
/**
* 权益卡片跳转:会员权利 / 派对权利 统一点击进对应能力页
*/
onBenefitTap(e) {
const key = e.currentTarget.dataset.key
if (!key) return
trackClick('vip', 'benefit_tap', key)
const tab = (path) => {
wx.switchTab({ url: path })
}
const nav = (path) => {
wx.navigateTo({ url: path })
}
const routes = {
match: () => tab('/pages/match/match'),
party: () => nav('/pages/mentors/mentors'),
rank: () => tab('/pages/index/index'),
top: () => tab('/pages/index/index'),
cases: () => tab('/pages/chapters/chapters'),
fullbook: () => tab('/pages/chapters/chapters'),
daily: () => tab('/pages/chapters/chapters'),
leads: () => nav('/pages/profile-edit/profile-edit')
}
const fn = routes[key]
if (typeof fn === 'function') fn()
},
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {

View File

@@ -21,7 +21,7 @@
<text class="rights-dot rights-dot-teal"></text>
<text class="rights-col-title">会员权利</text>
</view>
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
<view class="benefit-card benefit-card-tap" wx:for="{{contentRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
<icon name="{{item.icon || 'check'}}" size="40" color="#00CED1" customClass="benefit-icon"></icon>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>
@@ -34,7 +34,7 @@
<text class="rights-dot rights-dot-gold"></text>
<text class="rights-col-title rights-col-title-gold">派对权利</text>
</view>
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
<view class="benefit-card benefit-card-tap" wx:for="{{socialRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
<icon name="{{item.icon || 'check'}}" size="40" color="#FFD700" customClass="benefit-icon benefit-icon-gold"></icon>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>

View File

@@ -22,6 +22,8 @@
.rights-col-title { font-size: 24rpx; font-weight: bold; color: #4FD1C5; letter-spacing: 2rpx; }
.rights-col-title-gold { color: #FFBD2E; }
.benefit-card { display: flex; flex-direction: column; gap: 16rpx; padding: 24rpx; margin-bottom: 16rpx; background: #141414; border: 1rpx solid rgba(255,255,255,0.05); border-radius: 24rpx; }
.benefit-card-tap { transition: opacity 0.15s; }
.benefit-card-hover { opacity: 0.88; background: #1a1a1a; }
.benefit-icon { font-size: 36rpx; color: #4FD1C5; }
.benefit-icon-gold { color: #FFBD2E; }
.benefit-info { display: flex; flex-direction: column; }

View File

@@ -0,0 +1,11 @@
{
"size": {
"total": 1579970,
"packages": [
{
"name": "TOTAL",
"size": 1579970
}
]
}
}

View File

@@ -0,0 +1,14 @@
/**
* 小程序 <image src> 合法判断:避免 undefined 字符串、相对脏值触发「illegal src」
*/
function isSafeImageSrc(u) {
if (u == null) return false
const s = String(u).trim()
if (!s || s === 'undefined' || s === 'null') return false
if (/^https?:\/\//i.test(s)) return true
if (s.startsWith('wxfile://') || s.startsWith('cloud://')) return true
if (s.startsWith('/')) return true
return false
}
module.exports = { isSafeImageSrc }

View File

@@ -0,0 +1,39 @@
/**
* MBTI 默认头像:与后台 system_config.mbti_avatars + GET /api/miniprogram/config/mbti-avatars 一致
*/
const MBTI_RE = /^[EI][NS][FT][JP]$/
function normalizeMbti(m) {
const s = (m && String(m).trim().toUpperCase()) || ''
return MBTI_RE.test(s) ? s : ''
}
/**
* 展示用头像:优先用户已设头像(补全相对路径),否则合法 MBTI + 映射表中有 URL 则用映射
* @param {string} avatar
* @param {string} mbti
* @param {Record<string,string>} map
* @param {string} baseUrl
*/
function resolveAvatarWithMbti(avatar, mbti, map, baseUrl) {
let a = (avatar && String(avatar).trim()) || ''
if (a) {
if (!/^https?:\/\//i.test(a) && baseUrl) {
if (a.startsWith('/')) a = baseUrl + a
}
return a
}
const key = normalizeMbti(mbti)
if (!key || !map || typeof map !== 'object') return ''
let u = (map[key] && String(map[key]).trim()) || ''
if (!u) return ''
if (!/^https?:\/\//i.test(u) && baseUrl && u.startsWith('/')) u = baseUrl + u
return u
}
module.exports = {
MBTI_RE,
normalizeMbti,
resolveAvatarWithMbti,
}

View File

@@ -0,0 +1,26 @@
/**
* 按 mp_config.mpUi 配置的路径跳转Tab 页用 switchTab其余 navigateTo
*/
const TAB_PATHS = [
'/pages/index/index',
'/pages/chapters/chapters',
'/pages/match/match',
'/pages/my/my'
]
function navigateMpPath(path) {
if (!path || typeof path !== 'string') return false
const full = path.trim()
if (!full.startsWith('/')) return false
const q = full.indexOf('?')
const route = q >= 0 ? full.slice(0, q) : full
const suffix = q >= 0 ? full.slice(q) : ''
if (TAB_PATHS.includes(route)) {
wx.switchTab({ url: route })
return true
}
wx.navigateTo({ url: route + suffix })
return true
}
module.exports = { navigateMpPath, TAB_PATHS }

View File

@@ -0,0 +1,13 @@
/**
* 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致
* 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时)
*/
const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖']
/** 正文篇在列表中的从 0 开始的序号 → emoji与 ChapterTree bodyPartOrdinal 一致) */
function partEmojiForBodyIndex(bodyIndex) {
const i = Math.max(0, Number(bodyIndex) || 0)
return PART_ICONS[i % PART_ICONS.length]
}
module.exports = { PART_ICONS, partEmojiForBodyIndex }

View File

@@ -32,13 +32,13 @@ class ReadingTracker {
console.log('[ReadingTracker] 初始化追踪:', sectionId)
// 恢复上次阅读位置
this.saveProgressLocal()
app.touchRecentSection(sectionId)
this.restoreLastPosition(sectionId)
// 开始定期上报每30秒
this.startProgressReport()
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
setTimeout(() => this.reportProgressToServer(false), 0)
}
@@ -177,16 +177,20 @@ class ReadingTracker {
this.activeTracker.lastScrollTime = now
try {
const data = {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading'
}
if (this.activeTracker.isCompleted && this.activeTracker.completedAt != null) {
const t = this.activeTracker.completedAt
data.completedAt = typeof t === 'number' ? new Date(t).toISOString() : String(t)
}
await app.request('/api/miniprogram/user/reading-progress', {
method: 'POST',
data: {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
data
})
if (isCompletion) {

View File

@@ -1,10 +1,12 @@
/**
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
* 注册 → after_login(头像或昵称未完善)
* update_avatar / 完善头像 → 仅头像未完善
* update_nickname / 修改昵称 → 仅昵称为默认
* 点击收费章节 → before_read
* 完成匹配 → after_match
* 完成付款 → after_pay
@@ -34,9 +36,12 @@ const CACHE_TTL = 5 * 60 * 1000
const TRIGGER_SCENE_MAP = {
'注册': 'after_login',
'完善头像': 'after_login',
'修改昵称': 'after_login',
'点击收费章节': 'before_read',
'完成匹配': 'after_match',
'完成付款': 'after_pay',
'发起支付': 'before_pay',
'累计浏览5章节': 'page_show',
'加入派对房': 'before_join_party',
'绑定微信': 'after_bindwechat',
@@ -45,6 +50,10 @@ const TRIGGER_SCENE_MAP = {
'浏览导师页': 'browse_mentor',
}
// 与后台「规则类型 trigger」一致支持英文 key 与同义中文(逻辑在 isRuleEnabled 定义之后)
const TRIGGER_KEYS_AVATAR = ['update_avatar', '完善头像']
const TRIGGER_KEYS_NICKNAME = ['update_nickname', '修改昵称']
function isInCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false
try {
@@ -74,12 +83,44 @@ function getUserInfo() {
return app ? (app.globalData.userInfo || {}) : {}
}
function trimStr(v) {
if (v == null || v === undefined) return ''
const s = String(v).trim()
return s
}
/** 合并服务端 profile避免本地 userInfo 未同步导致「已填写仍弹窗」 */
async function fetchProfileMergeUser() {
const base = { ...getUserInfo() }
const userId = base.id
if (!userId) return base
try {
const app = getAppInstance()
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
if (res?.success && res.data) {
const d = res.data
return {
...base,
mbti: d.mbti != null ? d.mbti : base.mbti,
industry: d.industry != null ? d.industry : base.industry,
position: d.position != null ? d.position : base.position,
projectIntro: d.projectIntro || d.project_intro || base.projectIntro,
phone: d.phone != null ? d.phone : base.phone,
wechatId: d.wechatId || d.wechat_id || base.wechatId,
}
}
} catch (e) {}
return base
}
async function loadRules() {
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
const app = getAppInstance()
if (!app) return _cachedRules || []
const userId = (app.globalData.userInfo || {}).id || ''
try {
const res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
const url = userId ? `/api/miniprogram/user-rules?userId=${userId}` : '/api/miniprogram/user-rules'
const res = await app.request({ url, method: 'GET', silent: true })
if (res && res.success && res.rules) {
_cachedRules = res.rules
_cacheTs = Date.now()
@@ -92,36 +133,132 @@ async function loadRules() {
}
function isRuleEnabled(rules, triggerName) {
return rules.some(r => r.trigger === triggerName)
return rules.some(r => r.trigger === triggerName && !r.completed)
}
function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
return rules.find(r => r.trigger === triggerName && !r.completed)
}
// 稳定版:跳转 avatar-nickname专注头像+昵称,首次登录由 app.login 强制 redirect
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit避免与主流程冲突
function isAnyTriggerEnabled(rules, keys) {
return keys.some((k) => isRuleEnabled(rules, k))
}
function getFirstRuleInfo(rules, keys) {
for (let i = 0; i < keys.length; i++) {
const info = getRuleInfo(rules, keys[i])
if (info) return info
}
return null
}
function isAvatarMissingOrDefault(user) {
user = user || getUserInfo()
const avatar = user.avatar || user.avatarUrl || ''
return !avatar || avatar.includes('default')
}
function isNicknamePlaceholder(nickname) {
const n = trimStr(nickname)
return !n || n === '微信用户' || n.startsWith('微信用户')
}
function markRuleCompleted(ruleId) {
const userId = getUserInfo().id
if (!userId || !ruleId) return
const app = getAppInstance()
if (!app) return
const numericId = typeof ruleId === 'number' ? ruleId : null
if (!numericId) return
app.request({
url: '/api/miniprogram/user-rules/complete',
method: 'POST',
data: { userId, ruleId: numericId },
silent: true
}).catch(() => {})
}
// 仅头像trigger = update_avatar 或 完善头像
function checkRule_UpdateAvatar(rules) {
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_AVATAR)) return null
const app = getAppInstance()
if (app && app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
if (!isAvatarMissingOrDefault(user)) return null
if (isInCooldown('update_avatar')) return null
setCooldown('update_avatar')
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_AVATAR)
return {
ruleId: 'update_avatar',
serverRuleId: info?.id,
title: info?.title || '上传头像',
message: info?.description || '换一张清晰头像,伙伴在名片和匹配里更容易认出你。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname?focus=avatar'
}
}
// 仅昵称trigger = update_nickname 或 修改昵称
function checkRule_UpdateNickname(rules) {
if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_NICKNAME)) return null
const app = getAppInstance()
if (app && app.globalData.isVip) return null
const user = getUserInfo()
if (!user.id) return null
const nickname = user.nickname || user.nickName || ''
if (!isNicknamePlaceholder(nickname)) return null
if (isInCooldown('update_nickname')) return null
setCooldown('update_nickname')
const info = getFirstRuleInfo(rules, TRIGGER_KEYS_NICKNAME)
return {
ruleId: 'update_nickname',
serverRuleId: info?.id,
title: info?.title || '修改昵称',
message: info?.description || '改一个真实好记的昵称,方便伙伴称呼你。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname?focus=nickname'
}
}
// 稳定版trigger=注册 时头像或昵称任一未完善则引导(与上面两项拆分配置并存)
// 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 || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return null
const nickname = user.nickname || user.nickName || ''
if (!isAvatarMissingOrDefault(user) && !isNicknamePlaceholder(nickname)) return null
if (isInCooldown('fill_avatar')) return null
setCooldown('fill_avatar')
const info = getRuleInfo(rules, '注册')
const needNick = isNicknamePlaceholder(nickname)
const needAv = isAvatarMissingOrDefault(user)
const focus = needAv && !needNick ? 'avatar' : needNick && !needAv ? 'nickname' : ''
const qs = focus ? `?focus=${focus}` : ''
return {
ruleId: 'fill_avatar',
title: info?.title || '完善个人信息',
message: info?.description || '设置头像昵称,让其他创业者更容易认识你',
serverRuleId: info?.id,
title: info?.title || '设置头像昵称',
message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname'
target: '/pages/avatar-nickname/avatar-nickname' + qs
}
}
/** 头像/昵称类引导:优先拆条规则(完善头像、修改昵称),其次合并规则(注册) */
function checkAvatarNicknameGuides(rules) {
return checkRule_UpdateAvatar(rules) || checkRule_UpdateNickname(rules) || checkRule_FillAvatar(rules)
}
function checkRule_BindPhone(rules) {
if (!isRuleEnabled(rules, '点击收费章节')) return null
const user = getUserInfo()
@@ -132,25 +269,34 @@ function checkRule_BindPhone(rules) {
const info = getRuleInfo(rules, '点击收费章节')
return {
ruleId: 'bind_phone',
serverRuleId: info?.id,
title: info?.title || '绑定手机号',
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
message: info?.description || '绑定后可用于登录验证、收益与重要通知,账户安全',
confirmText: '去绑定',
cancelText: '关闭',
action: 'bind_phone',
target: null
}
}
function checkRule_FillProfile(rules) {
function checkRule_FillProfile(rules, user) {
if (!isRuleEnabled(rules, '完成匹配')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
if (user.mbti && user.industry) return null
const mbti = trimStr(user.mbti)
const industry = trimStr(user.industry)
const position = trimStr(user.position)
if (mbti && industry && position) return null
if (isInCooldown('fill_profile')) return null
setCooldown('fill_profile')
const info = getRuleInfo(rules, '完成匹配')
return {
ruleId: 'fill_profile',
title: info?.title || '完善创业档案',
message: info?.description || '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
serverRuleId: info?.id,
title: info?.title || '补充档案信息',
message: info?.description || '补全 MBTI、行业和职位后匹配页能更准确地向对方展示你减少无效沟通。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
@@ -169,45 +315,56 @@ function checkRule_ShareAfter5Chapters(rules) {
const info = getRuleInfo(rules, '累计浏览5章节')
return {
ruleId: 'share_after_5',
serverRuleId: info?.id,
title: info?.title || '邀请好友一起看',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,好友通过你的分享购买时,你可获得对应分销收益',
confirmText: '查看分享',
cancelText: '关闭',
action: 'navigate',
target: '/pages/referral/referral'
}
}
// 稳定版兼容hasPurchasedFull 用 hasFullBook
function checkRule_FillVipInfo(rules) {
function checkRule_FillVipInfo(rules, user) {
if (!isRuleEnabled(rules, '完成付款')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
const app = getAppInstance()
if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) return null
const wxId = trimStr(user.wechatId || user.wechat_id)
const addr = trimStr(user.address)
if (wxId && addr) return null
if (isInCooldown('fill_vip_info')) return null
setCooldown('fill_vip_info')
const info = getRuleInfo(rules, '完成付款')
return {
ruleId: 'fill_vip_info',
title: info?.title || '填写完整信息',
message: info?.description || '购买全书后,需填写完整信息以进入 VIP ',
serverRuleId: info?.id,
title: info?.title || '补全 VIP 资料',
message: info?.description || '补全微信号与收货地址等信息,便于进入 VIP 群、寄送物料与售后联系。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_JoinParty(rules) {
function checkRule_JoinParty(rules, user) {
if (!isRuleEnabled(rules, '加入派对房')) return null
const user = getUserInfo()
user = user || getUserInfo()
if (!user.id) return null
if (user.projectIntro) return null
if (trimStr(user.projectIntro)) return null
if (isInCooldown('join_party')) return null
setCooldown('join_party')
const info = getRuleInfo(rules, '加入派对房')
return {
ruleId: 'join_party',
title: info?.title || '填写项目介绍',
message: info?.description || '进入派对房前,引导填写项目介绍和核心需求',
serverRuleId: info?.id,
title: info?.title || '补充项目介绍',
message: info?.description || '用简短文字说明项目与需求,派对房里的伙伴能更快判断是否与你有合作空间。',
confirmText: '去填写',
cancelText: '关闭',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
@@ -217,14 +374,17 @@ function checkRule_BindWechat(rules) {
if (!isRuleEnabled(rules, '绑定微信')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.wechatId) return null
if (trimStr(user.wechatId || user.wechat_id)) return null
if (isInCooldown('bind_wechat')) return null
setCooldown('bind_wechat')
const info = getRuleInfo(rules, '绑定微信')
return {
ruleId: 'bind_wechat',
serverRuleId: info?.id,
title: info?.title || '绑定微信号',
message: info?.description || '绑定微信后,引导开启分销功能',
message: info?.description || '绑定后可用于分销结算、提现核对与重要通知。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/settings/settings'
}
@@ -242,8 +402,11 @@ function checkRule_Withdraw(rules) {
const info = getRuleInfo(rules, '收益满50元')
return {
ruleId: 'withdraw_50',
serverRuleId: info?.id,
title: info?.title || '可以提现了',
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
message: info?.description || '累计分销收益已达到提现条件,可在推荐收益页发起提现到微信零钱。',
confirmText: '去查看',
cancelText: '关闭',
action: 'navigate',
target: '/pages/referral/referral'
}
@@ -255,15 +418,17 @@ function checkRulesSync(scene, rules) {
switch (scene) {
case 'after_login':
return checkRule_FillAvatar(rules)
return checkAvatarNicknameGuides(rules)
case 'before_read':
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
return checkRule_BindPhone(rules) || checkAvatarNicknameGuides(rules)
case 'before_pay':
return checkAvatarNicknameGuides(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules)
case 'after_match':
return checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
return null
case 'after_pay':
return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules)
case 'page_show':
return checkRule_FillAvatar(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
case 'before_join_party':
return checkRule_JoinParty(rules)
default:
@@ -277,8 +442,8 @@ function executeRule(rule, pageInstance) {
wx.showModal({
title: rule.title,
content: rule.message,
confirmText: '去完善',
cancelText: '稍后再说',
confirmText: rule.confirmText || '去填写',
cancelText: rule.cancelText !== undefined ? rule.cancelText : '关闭',
success: (res) => {
if (res.confirm) {
if (rule.action === 'navigate' && rule.target) {
@@ -288,6 +453,9 @@ function executeRule(rule, pageInstance) {
pageInstance.showPhoneBinding()
}
}
if (rule.serverRuleId) {
markRuleCompleted(rule.serverRuleId)
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
@@ -309,10 +477,16 @@ function _trackRuleAction(ruleId, action) {
async function checkAndExecute(scene, pageInstance) {
const rules = await loadRules()
const rule = checkRulesSync(scene, rules)
let rule = null
if (scene === 'after_match') {
const u = await fetchProfileMergeUser()
rule = checkRule_FillProfile(rules, u) || checkRule_JoinParty(rules, u)
} else {
rule = checkRulesSync(scene, rules)
}
if (rule) {
setTimeout(() => executeRule(rule, pageInstance), 800)
}
}
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules }
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }

View File

@@ -32,6 +32,19 @@ const formatMoney = (amount, decimals = 2) => {
return Number(amount).toFixed(decimals)
}
/** 「我的」等页统计数字展示非法值→0≥1 万可缩写为「x万」 */
const formatStatNum = (n) => {
const x = Number(n)
if (Number.isNaN(x) || !Number.isFinite(x)) return '0'
const v = Math.floor(x)
if (v >= 10000) {
const w = v / 10000
const s = w >= 10 ? String(Math.floor(w)) : String(Math.round(w * 10) / 10).replace(/\.0$/, '')
return s + '万'
}
return String(v)
}
// 防抖函数
const debounce = (fn, delay = 300) => {
let timer = null
@@ -189,6 +202,7 @@ module.exports = {
formatTime,
formatDate,
formatMoney,
formatStatNum,
formatNumber,
debounce,
throttle,

View File

@@ -1,48 +1,47 @@
# Soul 运营全链路技能包(本机一键打包)
# Soul 运营全链路技能包(精简打包)
## 你要做的事(复制到另一台电脑前
## 一键打包(推荐:体积小、可重装
**本机终端** 执行(路径按你实际安装调整)
**本机终端** 执行:
```bash
python3 "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/scripts/pack_soul_operation_skills.py"
```
- 会在 **`~/Downloads/Soul运营全链路技能包_20260320.zip`** 生成压缩包(日期戳见脚本内 `STAMP`,可自行改)。
- 临时文件在永平项目下 **`.tmp_skill_bundle/`**,打包完成后可整目录删除
- 输出:**`~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip`**(日期为**打包当天**)。
- 临时目录:`一场soul的创业实验-永平/.tmp_skill_bundle/`,打完可删
卡若AI不在默认路径,请先编辑 `pack_soul_operation_skills.py` 里的
卡若AI不在默认路径时,编辑脚本内
```python
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
```
## 压缩包里有什么(保证链路齐全
## 精简包策略(大文件不进包
| 内容 | 说明 |
| 类型 | 处理 |
|:---|:---|
| `.cursor/skills/soul-operation-report` | Cursor 入口:运营报表 |
| `.cursor/skills/soul-party-project` | Cursor 入口:水岸项目管理 |
| `卡若AI/02_卡人/水岸_项目管理/` | 水岸总纲 + 卡若创业派对 README |
| `卡若AI/.../水桥_平台对接/飞书管理/` | 运营报表、妙记相关脚本与 SKILL |
| `卡若AI/.../水桥_平台对接/智能纪要/` | 妙记下载、纪要 SKILL + 脚本 |
| `卡若AI/.../水桥_平台对接/Soul创业实验/` | 写作/上传/环境与 TOKEN 说明 |
| `卡若AI/03_卡木/木叶_视频内容/` 下 | `视频切片``多平台分发``抖音/B站/视频号/小红书/快手发布` |
| `卡若AI/运营中枢/工作台/00_账号与API索引.md` | 若本机存在则一并打入(凭证速查) |
| `解压后必读.md` | 在另一台电脑上的合并步骤与环境说明 |
| 单文件 **> 512KB** | 跳过 |
| 视频/音频/压缩包/模型权重等扩展名 | 跳过 |
| `cookies/``node_modules``.browser_state``venv` 等 | 整目录跳过 |
| `publish_log.json``.feishu_tokens.json` | 跳过(到新机按脚本重新授权;若要迁移凭证请**单独**安全拷贝) |
## 「可直接运作」在另一台机上的含义
包内另有 **`重装依赖说明.md`**、**`_pack_stats.json`**(本次打入/跳过统计)。
- **Skill 与脚本文件**会齐;但要真正跑通,仍需在新电脑上:
- 安装 **Python、依赖、FFmpeg、conda/mlx-whisper**(见各 SKILL
- 配置 **飞书/妙记/各平台 Cookie、小程序与永平项目 `.env`**(见 `Soul创业实验/上传/环境与TOKEN配置.md``00_账号与API索引.md`
- 把文档里原机的 **`/Users/karuo/...`** 改成新机器路径。
## 压缩包里有什么(链路齐全 = SKILL + 脚本 + 小配置)
- `.cursor/skills/``soul-operation-report``soul-party-project`
- `卡若AI/02_卡人/水岸_项目管理/`
- `卡若AI/.../水桥_平台对接/飞书管理/``智能纪要/``Soul创业实验/`
- `卡若AI/03_卡木/木叶_视频内容/``视频切片``多平台分发`、各平台发布(仅小文件)
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若存在且小于体积限制)
## 另一台电脑
1. 解压 → 合并 `卡若AI/` → 安装 Cursor skills。
2.**`重装依赖说明.md`**`pip`/`conda`/`ffmpeg`/`playwright` 等。
3. 重新配置飞书 Token、妙记 Cookie、各平台 Cookie、永平 `.env`
## 安全
压缩包可能含 **密钥与 Cookie 说明**,请勿上传公开网盘;用 U 盘或加密渠道传输。
## 未打入的内容(属正常)
- **`飞书管理/脚本/.browser_state/`**Playwright/Chrome 本地状态,常含断链或套接字文件,打包会失败且不宜迁移;**到新电脑需按各脚本说明重新登录/生成状态**。
- 体积约 **260MB+**(含脚本与分发相关文件);若需更小体积,可自行从包内删掉用不到的平台目录后再压缩。
勿将含密钥的压缩包上传公开网盘;用 U 盘或加密渠道传输。

View File

@@ -13,8 +13,8 @@ if [[ ! -x "$CLI" ]]; then
exit 1
fi
# 未传参时默认 1.7.1(避免手滑打成 1.17 等与展示不一致)
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.1}"
# 未传参时默认 1.7.2(避免手滑打成 1.17 等与展示不一致)
DEFAULT_VER="${MINIPROGRAM_DEFAULT_VERSION:-1.7.2}"
VERSION="${1:-$DEFAULT_VER}"
DESC="${2:-版本 v$VERSION}"

View File

@@ -1,48 +1,213 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soul 运营全链路技能包:将 SKILL + 脚本 + Cursor 入口打成 zip默认输出到用户「下载」文件夹
Soul 运营全链路技能包(精简版):只打包 SKILL / 脚本 / 小配置,便于另一台机 pip/conda 重装
大文件、媒体、Cookie、日志等一律不入包。
用法:
python3 scripts/pack_soul_operation_skills.py
输出:
~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip
"""
from __future__ import annotations
import datetime as _dt
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
STAMP = "20260320"
BUNDLE_TOP = f"Soul运营全链路技能包_{STAMP}"
# 单文件超过此大小则跳过(字节)——非「代码/文档类」扩展名
MAX_FILE_BYTES = 512 * 1024 # 512KB
# 脚本与文档类可放宽(避免误跳过大 .py/.md仍远小于整包 200MB+
CODE_DOC_EXT = frozenset(
{
".py",
".md",
".mdc",
".sh",
".bash",
".zsh",
".txt",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
".sql",
".html",
".css",
".js",
".ts",
".tsx",
".jsx",
".svg",
".xml",
}
)
MAX_CODE_DOC_BYTES = 8 * 1024 * 1024 # 8MB
# 整段目录名匹配则不进包walk 时不进入)
SKIP_DIR_NAMES = frozenset(
{
"__pycache__",
".git",
".svn",
".browser_state",
"chromium_data",
"node_modules",
"venv",
".venv",
".mypy_cache",
".pytest_cache",
".tox",
"dist",
"build",
"eggs",
".eggs",
"htmlcov",
".ruff_cache",
# Cookie 到新机需重新登录导出,不入包
"cookies",
}
)
# 扩展名一律跳过(媒体/模型/压缩包等)
SKIP_EXTENSIONS = frozenset(
{
".mp4",
".mov",
".mkv",
".avi",
".webm",
".m4v",
".flv",
".wmv",
".zip",
".tar",
".gz",
".tgz",
".bz2",
".xz",
".rar",
".7z",
".dmg",
".iso",
".img",
".pt",
".pth",
".onnx",
".ckpt",
".safetensors",
".bin",
".exe",
".dll",
".so",
".dylib",
".wav",
".mp3",
".flac",
".aac",
".m4a",
".npz",
".npy",
".pkl",
".pickle",
".whl",
".parquet",
".arrow",
}
)
# 文件名(不含路径)强制跳过
SKIP_FILE_NAMES = frozenset(
{
".DS_Store",
"Thumbs.db",
"publish_log.json", # 分发日志可能巨大
".feishu_tokens.json", # 凭证,到新机用脚本重新获取更安全;若需带走可自行拷贝
}
)
# 卡若AI 根目录(按你本机实际修改)
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
CURSOR_SKILLS = Path.home() / ".cursor" / "skills"
DOWNLOADS = Path.home() / "Downloads"
# 在永平项目下临时组装(本仓库内,便于工具写入)
REPO_ROOT = Path(__file__).resolve().parents[1]
STAMP = _dt.date.today().strftime("%Y%m%d")
BUNDLE_TOP = f"Soul运营全链路技能包_精简_{STAMP}"
STAGING_PARENT = REPO_ROOT / ".tmp_skill_bundle"
STAGING = STAGING_PARENT / BUNDLE_TOP
def ignore_copy(dirpath: str, names: list[str]) -> list[str]:
"""排除缓存、浏览器运行时目录(含断链/套接字,会导致 copytree 失败)。"""
skip_dirs = {"__pycache__", ".browser_state", "chromium_data"}
skip_files = {".DS_Store"}
ignored: list[str] = []
for n in names:
if n in skip_dirs or n in skip_files or n.endswith(".pyc"):
ignored.append(n)
return ignored
# 统计
_stats: dict[str, int] = {"files": 0, "skipped_size": 0, "skipped_ext": 0, "skipped_dir": 0, "skipped_name": 0}
def copytree(src: Path, dst: Path) -> None:
if not src.exists():
print(f"SKIP 不存在: {src}", file=sys.stderr)
def should_skip_file(path: Path) -> tuple[bool, str]:
name = path.name
if name in SKIP_FILE_NAMES:
return True, "name"
ext = path.suffix.lower()
if ext in SKIP_EXTENSIONS:
return True, "ext"
try:
sz = path.stat().st_size
except OSError:
return True, "stat"
limit = MAX_CODE_DOC_BYTES if ext in CODE_DOC_EXT else MAX_FILE_BYTES
if sz > limit:
return True, "size"
return False, ""
def copy_tree_selective(src: Path, dst_root: Path, rel_base: Path) -> None:
"""将 src 下文件复制到 dst_root / rel_base遵守跳过规则。"""
if not src.is_dir():
print(f"SKIP 非目录: {src}", file=sys.stderr)
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True, ignore=ignore_copy)
for root, dirnames, filenames in os_walk_topdown(src):
root_path = Path(root)
# 过滤要进入的子目录
for d in list(dirnames):
if d in SKIP_DIR_NAMES:
dirnames.remove(d)
_stats["skipped_dir"] += 1
rel = root_path.relative_to(src)
for fname in filenames:
fp = root_path / fname
skip, reason = should_skip_file(fp)
if skip:
if reason == "size":
_stats["skipped_size"] += 1
elif reason == "ext":
_stats["skipped_ext"] += 1
elif reason == "name":
_stats["skipped_name"] += 1
continue
dest_dir = dst_root / rel_base / rel
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / fname
shutil.copy2(fp, dest)
_stats["files"] += 1
def os_walk_topdown(src: Path):
"""与 os.walk 相同,但用 Path。"""
for r, dnames, fnames in os.walk(str(src), topdown=True):
yield Path(r), dnames, fnames
def copy_cursor_skill(name: str) -> None:
src = CURSOR_SKILLS / name
if not src.is_dir():
print(f"SKIP 无 Cursor skill: {src}", file=sys.stderr)
return
copy_tree_selective(src, STAGING, Path(".cursor") / "skills" / name)
def main() -> int:
@@ -50,29 +215,35 @@ def main() -> int:
print(f"ERROR: 未找到卡若AI目录: {KARUO_AI}", file=sys.stderr)
return 1
global _stats
_stats = {k: 0 for k in _stats}
if STAGING.exists():
shutil.rmtree(STAGING)
STAGING.mkdir(parents=True)
# Cursor 入口
csk = STAGING / ".cursor" / "skills"
csk.mkdir(parents=True, exist_ok=True)
# Cursor 入口(通常只有 SKILL.md
for name in ("soul-operation-report", "soul-party-project"):
p = CURSOR_SKILLS / name
if p.is_dir():
copytree(p, csk / name)
copy_cursor_skill(name)
kai = STAGING / "卡若AI"
copytree(
kai_rel = Path("卡若AI")
def pack_sub(src_under_karuo: Path, rel_under_kai: Path) -> None:
"""src_under_karuo 为卡若AI下的绝对路径打入包内 卡若AI/rel_under_kai"""
if not src_under_karuo.exists():
print(f"SKIP 不存在: {src_under_karuo}", file=sys.stderr)
return
copy_tree_selective(src_under_karuo, STAGING, kai_rel / rel_under_kai)
pack_sub(
KARUO_AI / "02_卡人" / "水岸_项目管理",
kai / "02_卡人" / "水岸_项目管理",
Path("02_卡人") / "水岸_项目管理",
)
bridge = KARUO_AI / "02_卡人" / "水桥_平台对接"
for sub in ("飞书管理", "智能纪要", "Soul创业实验"):
copytree(bridge / sub, kai / "02_卡人" / "水桥_平台对接" / sub)
pack_sub(bridge / sub, Path("02_卡人") / "水桥_平台对接" / sub)
wood = KARUO_AI / "03_卡木" / "木叶_视频内容"
wdst = kai / "03_卡木" / "木叶_视频内容"
for sub in (
"视频切片",
"多平台分发",
@@ -82,45 +253,74 @@ def main() -> int:
"小红书发布",
"快手发布",
):
copytree(wood / sub, wdst / sub)
pack_sub(wood / sub, Path("03_卡木") / "木叶_视频内容" / sub)
idx = KARUO_AI / "运营中枢" / "工作台" / "00_账号与API索引.md"
if idx.is_file():
(kai / "运营中枢" / "工作台").mkdir(parents=True, exist_ok=True)
shutil.copy2(idx, kai / "运营中枢" / "工作台" / idx.name)
skip, _ = should_skip_file(idx)
if not skip:
dest = STAGING / kai_rel / "运营中枢" / "工作台" / idx.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(idx, dest)
_stats["files"] += 1
readme = STAGING / "解压后必读.md"
readme.write_text(
"""# Soul 运营全链路技能包
# 写入 requirements 汇总(若各目录有 requirements.txt只列路径提示不合并
req_hint = STAGING / "重装依赖说明.md"
req_hint.write_text(
f"""# 重装依赖说明(精简包)
## 包含内容
本包**不含**大文件与本地状态,到新电脑请:
- `.cursor/skills/``soul-operation-report`、`soul-party-project`
- `卡若AI/02_卡人/水岸_项目管理/`
- `卡若AI/02_卡人/水桥_平台对接/飞书管理/`、`智能纪要/`、`Soul创业实验/`
- `卡若AI/03_卡木/木叶_视频内容/`:视频切片、多平台分发、各平台发布
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若源机存在)
1. **Python**:建议 3.10+;进入各含 `requirements.txt` 的脚本目录执行 `pip install -r requirements.txt`(以各 SKILL 为准)。
2. **系统**`ffmpeg`、`ffprobe`(视频切片);视频转录见 SKILL 中的 **conda mlx-whisper** 环境说明。
3. **Playwright**(若飞书脚本需要):`playwright install` 并按脚本说明登录;**`.browser_state` 未打包**。
4. **多平台分发**:包内**不含 `cookies/` 目录**,需在新机各平台重新登录导出 Cookie见多平台分发 SKILL
5. **飞书 Token**:精简包默认**不含** `.feishu_tokens.json`,请在新机用脚本流程重新授权;若你刻意要迁移凭证请单独拷贝(注意安全)。
## 另一台电脑怎么用
---
1. 解压后,将 `卡若AI/` **合并**到你本机卡若AI根目录先备份
2. 将 `.cursor/skills/` 下两个目录复制到 `~/.cursor/skills/`。
3. 安装 Python/FFmpeg/conda 等依赖,按各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md` 配置 Token、Cookie、永平项目 `.env`。
4. 文档或脚本里的 `/Users/karuo/...` 需改成本机路径。
打包策略摘要(自动生成):
**安全**:包内可能有凭证说明,勿上传公开网盘。
- 代码/文档类(`.py`、`.md` 等)单文件大于 **{MAX_CODE_DOC_BYTES // (1024 * 1024)} MB** 跳过;其它类型大于 **{MAX_FILE_BYTES // 1024} KB** 跳过
- 跳过扩展名:媒体、压缩包、模型权重等
- 跳过目录:`cookies`、`node_modules`、`.browser_state`、`venv` 等
打包日期:**{STAMP}**
""",
encoding="utf-8",
)
for p in list(STAGING.rglob("__pycache__")):
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
for p in STAGING.rglob("*.pyc"):
try:
p.unlink()
except OSError:
pass
readme = STAGING / "解压后必读.md"
readme.write_text(
f"""# Soul 运营全链路技能包(精简版)
## 本包特点
- **体积小**:不含视频/大日志/模型/Cookie 目录等;到新机器按 `重装依赖说明.md` **重装环境与凭证**。
- **日期**{STAMP}
## 包含
- `.cursor/skills/``soul-operation-report`、`soul-party-project`
- `卡若AI/` 下水岸、飞书管理、智能纪要、Soul创业实验、视频切片、多平台分发与各平台发布目录中的 **SKILL、脚本、小配置**(受大小与类型过滤)
## 合并步骤
1. 解压后把 `卡若AI/` **合并**进你的卡若AI根目录先备份
2. 将 `.cursor/skills/` 下两个文件夹复制到 `~/.cursor/skills/`。
3. 阅读 **`重装依赖说明.md`**,安装 Python 依赖、FFmpeg、conda 环境等。
4. 配置飞书、妙记、各平台 Cookie、永平 `.env`(见各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md`)。
**安全**:勿将含密钥的压缩包上传公开网盘。
""",
encoding="utf-8",
)
# 打包统计写入 JSON便于核对
(STAGING / "_pack_stats.json").write_text(
json.dumps({**_stats, "max_file_bytes": MAX_FILE_BYTES, "stamp": STAMP}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
zip_path = DOWNLOADS / f"{BUNDLE_TOP}.zip"
@@ -132,7 +332,11 @@ def main() -> int:
zf.write(f, arcname.as_posix())
mb = zip_path.stat().st_size / (1024 * 1024)
print(f"完成: {zip_path} ({mb:.2f} MB)")
print(f"完成: {zip_path}")
print(f"大小: {mb:.2f} MB | 打入文件数: {_stats['files']}")
print(
f"跳过: 超体积 {_stats['skipped_size']} | 扩展名 {_stats['skipped_ext']} | 文件名 {_stats['skipped_name']} | 目录 {_stats['skipped_dir']}"
)
print(f"临时目录(可删): {STAGING}")
return 0

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到 Soul 彩民团队飞书群(默认 webhook)。
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到开发群(默认 webhook见 FEISHU_DEV_GROUP_WEBHOOK)。
海报样式:深蓝背景、顶部装饰条、主标题为章节标题、摘要、底部「长按识别小程序码」+ 二维码。
用法:
python3 send_chapter_poster_to_feishu.py 9.24 "第112场一个人起头维权挣了大半套房"
@@ -30,8 +30,11 @@ except ImportError:
# 与 post_to_feishu 保持一致
SCRIPT_DIR = Path(__file__).resolve().parent
# 默认发到 Soul 彩民团队飞书群
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
# 默认Soul 开发群(派对 AI / 卡若 AI 与项目复盘统一入口,见 .cursor/docs/feishu_开发群与项目复盘.md
WEBHOOK = os.environ.get(
"FEISHU_DEV_GROUP_WEBHOOK",
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
)
BACKEND_QRCODE_URL = "https://soulapi.quwanzhi.com/api/miniprogram/qrcode"
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
向开发群 webhook 发送一条长文本 + 若干本地 PNG先上传飞书再发 image_key
依赖:与 send_chapter_poster_to_feishu.py 相同,需 scripts/.env.feishu 内 FEISHU_APP_ID / FEISHU_APP_SECRET。
用法:
python3 send_feishu_text_and_images.py --text-file recap.txt \\
--images a.png b.png
python3 send_feishu_text_and_images.py -t "单行文本" --images x.png
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
try:
import requests
except ImportError:
print("pip install requests", file=sys.stderr)
sys.exit(1)
DEFAULT_WEBHOOK = os.environ.get(
"FEISHU_DEV_GROUP_WEBHOOK",
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
)
def load_env_feishu():
p = SCRIPT_DIR / ".env.feishu"
if not p.is_file():
return
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
def tenant_token() -> str | None:
load_env_feishu()
app_id = os.environ.get("FEISHU_APP_ID", "")
sec = os.environ.get("FEISHU_APP_SECRET", "")
if not app_id or not sec:
print("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET.env.feishu", file=sys.stderr)
return None
r = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": sec},
timeout=15,
)
data = r.json() or {}
if data.get("code") != 0:
print("token 失败:", data, file=sys.stderr)
return None
return data.get("tenant_access_token")
def send_text(webhook: str, text: str) -> bool:
r = requests.post(webhook, json={"msg_type": "text", "content": {"text": text}}, timeout=15)
d = r.json() or {}
if d.get("code") != 0:
print("文本发送失败:", d, file=sys.stderr)
return False
return True
def upload_png(token: str, path: Path) -> str | None:
url = "https://open.feishu.cn/open-apis/im/v1/images"
headers = {"Authorization": f"Bearer {token}"}
with path.open("rb") as f:
r = requests.post(
url,
headers=headers,
files={"image": (path.name, f, "image/png")},
data={"image_type": "message"},
timeout=60,
)
out = r.json() or {}
if out.get("code") != 0:
print("上传失败", path, out, file=sys.stderr)
return None
return (out.get("data") or {}).get("image_key")
def send_image(webhook: str, image_key: str) -> bool:
r = requests.post(
webhook,
json={"msg_type": "image", "content": {"image_key": image_key}},
timeout=15,
)
d = r.json() or {}
if d.get("code") != 0:
print("图片消息失败:", d, file=sys.stderr)
return False
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-t", "--text", default="", help="直接传入文本")
ap.add_argument("--text-file", type=Path, help="从文件读文本utf-8")
ap.add_argument("--webhook", "-w", default=DEFAULT_WEBHOOK)
ap.add_argument("--images", "-i", nargs="*", default=[], help="PNG 路径列表")
args = ap.parse_args()
body = args.text.strip()
if args.text_file:
body = args.text_file.read_text(encoding="utf-8").strip()
if not body:
ap.error("需要 -t 或 --text-file")
if not send_text(args.webhook, body[:20000]):
sys.exit(1)
print("已发文本")
if not args.images:
return
tok = tenant_token()
if not tok:
sys.exit(1)
for p in args.images:
path = Path(p).expanduser().resolve()
if not path.is_file():
print("跳过(不存在):", path, file=sys.stderr)
continue
key = upload_png(tok, path)
if key and send_image(args.webhook, key):
print("已发图:", path.name)
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,14 @@
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
@@ -17,7 +19,7 @@ class Route:
full_path: str # full path appended to API_BASE_URL
def _read_text(path: str) -> str:
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()

View File

@@ -1,10 +1,12 @@
import re
from dataclasses import dataclass
from pathlib import Path
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
PROJECT_ROOT = Path(__file__).resolve().parents[3]
ROUTER_GO = PROJECT_ROOT / "soul-api" / "internal" / "router" / "router.go"
@dataclass
@@ -15,7 +17,7 @@ class Check:
preview: str
def _read_text(path: str) -> str:
def _read_text(path: Path) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()

View File

@@ -4,9 +4,8 @@
重要说明(微信官方限制):
- 代码上传可用本机「微信开发者工具」CLI 或 miniprogram-ci。
- submit_audit开放平台文档标明主要为「第三方平台代调用」;自有主体使用小程序 appid+secret
换取的 access_token 调用时,常见返回 errcode=86000仅允许第三方代调用此时必须在
mp 后台手动点「提交审核」。
- submit_audit主要为「第三方平台代调用」自有主体 appid+secret 常返回 errcode=86000
无法在仓库内替代网页提审;`release` 默认只跑上传+接口调用,不弹浏览器、不提示手动操作。
- 「自动过审」不可能由开发者脚本保证:是否通过由微信审核决定。
"""
from __future__ import annotations
@@ -149,6 +148,8 @@ def cmd_submit_audit(
version_desc: str,
item_json: Path | None,
privacy_api_not_use: bool | None,
*,
quiet: bool = False,
) -> dict:
token = get_access_token(appid, secret)
if item_json and item_json.is_file():
@@ -184,18 +185,19 @@ def cmd_submit_audit(
except urllib.error.HTTPError as e:
raise SystemExit(f"submit_audit HTTP 错误: {e}") from e
print(json.dumps(data, ensure_ascii=False, indent=2))
if data.get("errcode") == 86000:
print(
"\n说明 errcode=86000该接口仅支持「第三方平台」代小程序调用。"
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
"可先运行: python3 scripts/wechat_miniprogram_release.py open-mp",
file=sys.stderr,
)
elif data.get("errcode") == 61039:
print(
"\n说明 errcode=61039上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
file=sys.stderr,
)
if not quiet:
if data.get("errcode") == 86000:
print(
"\n说明 errcode=86000该接口仅支持「第三方平台」代小程序调用。"
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
"可先运行: python3 scripts/wechat_miniprogram_release.py open-version",
file=sys.stderr,
)
elif data.get("errcode") == 61039:
print(
"\n说明 errcode=61039上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
file=sys.stderr,
)
return data
@@ -245,8 +247,8 @@ def main() -> None:
p_up.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
help="版本号,默认 1.7.1 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
help="版本号,默认 1.7.2 或环境变量 MINIPROGRAM_DEFAULT_VERSION",
)
p_up.add_argument(
"--desc",
@@ -287,15 +289,18 @@ def main() -> None:
p_uo.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
p_rel = sub.add_parser("release", help="先 upload 再 submit-audit提审失败仍可到后台操作")
p_rel = sub.add_parser(
"release",
help="上传 → 尝试 submit_audit默认不弹浏览器、不提示手动打开可加 --open-browser",
)
p_rel.add_argument(
"--version",
"-v",
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.1"),
default=os.environ.get("MINIPROGRAM_DEFAULT_VERSION", "1.7.2"),
)
p_rel.add_argument("--desc", "-d", default="", help="上传说明,默认:版本 v<版本号>")
p_rel.add_argument("--version-desc", default="", help="提交审核说明,默认同上传说明")
@@ -305,6 +310,11 @@ def main() -> None:
action=argparse.BooleanOptionalAction,
default=None,
)
p_rel.add_argument(
"--open-browser",
action="store_true",
help="完成后打开公众平台版本管理页(默认关闭)",
)
args = p.parse_args()
@@ -336,18 +346,17 @@ def main() -> None:
if args.cmd == "release":
d = args.desc.strip() or f"版本 v{args.version}"
cmd_upload(args.version, d)
if not appid or not secret:
print(
"未设置 WECHAT_APPID / WECHAT_APPSECRET跳过 submit-audit",
file=sys.stderr,
if appid and secret:
vd = (args.version_desc or "").strip() or d
cmd_submit_audit(
appid,
secret,
vd,
args.item_json,
args.privacy_api_not_use,
quiet=True,
)
cmd_open_mp_version()
return
vd = (args.version_desc or "").strip() or d
res = cmd_submit_audit(
appid, secret, vd, args.item_json, args.privacy_api_not_use
)
if res.get("errcode") == 86000:
if getattr(args, "open_browser", False):
cmd_open_mp_version()
return

File diff suppressed because one or more lines are too long

1006
soul-admin/dist/assets/index-Dk6CvQRe.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

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-etcBHhA9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DYq6N0y0.css">
<script type="module" crossorigin src="/assets/index-Dk6CvQRe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
</head>
<body>
<div id="root"></div>

View File

@@ -37,9 +37,11 @@ export interface LinkTagItem {
label: string
aliases?: string
url: string
type: 'url' | 'miniprogram' | 'ckb'
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
appId?: string
pagePath?: string
/** 管理端列表用:库内是否已存目标小程序 AppSecret接口不下发明文 */
hasAppSecret?: boolean
}
/** 插入附件 HTML 时转义,防 XSS */

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useCallback } from 'react'
import { get, post } from '@/api/client'
import toast from '@/utils/toast'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Smile, Save, RefreshCw, WandSparkles } from 'lucide-react'
import {
MBTI_TYPES_ORDERED,
MBTI_AVATAR_PROFILES,
buildMbtiSvgAvatarDataUrl,
type MbtiType,
} from '@/lib/mbtiAvatarPrompts'
export function MbtiAvatarsManager() {
const [avatars, setAvatars] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [generating, setGenerating] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
if (res?.avatars) setAvatars(res.avatars)
else setAvatars({})
} catch {
toast.error('加载 MBTI 头像配置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const save = async () => {
setSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars })
if (!res || res.success === false) {
toast.error(res?.error || '保存失败')
return
}
toast.success('已保存,后台与小程序默认头像同步生效')
load()
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const generateOne = (type: MbtiType) => {
const dataUrl = buildMbtiSvgAvatarDataUrl(type)
setAvatars((prev) => ({ ...prev, [type]: dataUrl }))
toast.success(`${type} 已生成`)
}
const generateAll = () => {
setGenerating(true)
try {
const next = { ...avatars }
MBTI_TYPES_ORDERED.forEach((t) => {
next[t] = buildMbtiSvgAvatarDataUrl(t)
})
setAvatars(next)
toast.success('16 型头像已生成(仅人物)')
} finally {
setGenerating(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-gray-400">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-[#38bdac]" />
</div>
)
}
return (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-[#38bdac]/25 shadow-xl">
<CardHeader className="pb-2">
<CardTitle className="text-white flex items-center gap-2 text-lg">
<Smile className="w-5 h-5 text-[#38bdac]" />
MBTI
</CardTitle>
<CardDescription className="text-gray-400 text-sm leading-relaxed">
MBTI
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="bg-[#38bdac] hover:bg-[#2da396]" onClick={generateAll} disabled={generating}>
<WandSparkles className="w-3.5 h-3.5 mr-1" />
{generating ? '生成中…' : '一键生成16头像'}
</Button>
<Button type="button" size="sm" variant="outline" className="border-gray-600 text-gray-300" onClick={load}>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
</Button>
<Button type="button" size="sm" className="bg-emerald-600 hover:bg-emerald-500" onClick={save} disabled={saving}>
<Save className="w-3.5 h-3.5 mr-1" />
{saving ? '保存中…' : '保存映射'}
</Button>
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{MBTI_TYPES_ORDERED.map((t) => {
const url = avatars[t] ?? ''
const meta = MBTI_AVATAR_PROFILES[t]
return (
<div
key={t}
className="rounded-xl border border-gray-700/60 bg-[#0a1628] p-3 flex flex-col gap-2 hover:border-[#38bdac]/35 transition-colors"
>
<div className="flex items-center gap-2">
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0 font-mono text-xs">{t}</Badge>
<span className="text-xs text-gray-400 truncate" title={meta.title}>
{meta.title}
</span>
</div>
<div className="flex items-center gap-3">
<div className="w-16 h-16 rounded-full shrink-0 overflow-hidden flex items-center justify-center bg-[#081322] ring-2 ring-[#38bdac]/40 ring-offset-2 ring-offset-[#0a1628]">
{url ? (
<img src={url} alt={t} className="w-full h-full object-cover scale-110" />
) : (
<span className="text-gray-600 text-[10px]"></span>
)}
</div>
<div className="flex-1 min-w-0">
<Input
className="bg-[#162840] border-gray-700 text-white h-8 text-xs"
placeholder="https://... 或 data:image/..."
value={url}
onChange={(e) => setAvatars((prev) => ({ ...prev, [t]: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] border-[#38bdac]/40 text-[#38bdac]"
onClick={() => generateOne(t)}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-[11px] text-gray-400"
onClick={() => setAvatars((prev) => ({ ...prev, [t]: '' }))}
>
</Button>
</div>
</div>
)
})}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
export const MBTI_TYPES_ORDERED = [
'INTJ',
'INTP',
'ENTJ',
'ENTP',
'INFJ',
'INFP',
'ENFJ',
'ENFP',
'ISTJ',
'ISFJ',
'ESTJ',
'ESFJ',
'ISTP',
'ISFP',
'ESTP',
'ESFP',
] as const
export type MbtiType = (typeof MBTI_TYPES_ORDERED)[number]
type MbtiGroup = 'NT' | 'NF' | 'SJ' | 'SP'
type MbtiAvatarMood = 'calm' | 'sharp' | 'warm' | 'playful'
export interface MbtiAvatarProfile {
title: string
group: MbtiGroup
mood: MbtiAvatarMood
}
/**
* 以用户给的参考图为基准:多边形人物、无中英文字,仅保留人物头像。
* 颜色与网站深色主题融合(青绿/琥珀/紫青等低饱和高对比)。
*/
export const MBTI_AVATAR_PROFILES: Record<MbtiType, MbtiAvatarProfile> = {
INTJ: { title: '战略家', group: 'NT', mood: 'sharp' },
INTP: { title: '逻辑学家', group: 'NT', mood: 'calm' },
ENTJ: { title: '指挥官', group: 'NT', mood: 'sharp' },
ENTP: { title: '辩论家', group: 'NT', mood: 'playful' },
INFJ: { title: '提倡者', group: 'NF', mood: 'warm' },
INFP: { title: '调停者', group: 'NF', mood: 'warm' },
ENFJ: { title: '主人公', group: 'NF', mood: 'warm' },
ENFP: { title: '竞选者', group: 'NF', mood: 'playful' },
ISTJ: { title: '物流师', group: 'SJ', mood: 'calm' },
ISFJ: { title: '守卫者', group: 'SJ', mood: 'warm' },
ESTJ: { title: '总经理', group: 'SJ', mood: 'sharp' },
ESFJ: { title: '执政官', group: 'SJ', mood: 'warm' },
ISTP: { title: '鉴赏家', group: 'SP', mood: 'sharp' },
ISFP: { title: '探险家', group: 'SP', mood: 'playful' },
ESTP: { title: '企业家', group: 'SP', mood: 'playful' },
ESFP: { title: '表演者', group: 'SP', mood: 'playful' },
}
function paletteByGroup(group: MbtiGroup) {
switch (group) {
case 'NT':
return { bg: '#0d1424', body: '#c89a2c', accent: '#ffd66b', hair: '#6d540f', line: '#111827' }
case 'NF':
return { bg: '#0a1721', body: '#2e9f7c', accent: '#84e9c9', hair: '#2d6a4f', line: '#11212a' }
case 'SJ':
return { bg: '#101828', body: '#4f8cb8', accent: '#9bd4ff', hair: '#2e4a66', line: '#111f2d' }
case 'SP':
return { bg: '#161225', body: '#8b6bc0', accent: '#ccb3ff', hair: '#574183', line: '#211832' }
default:
return { bg: '#0e1422', body: '#38bdac', accent: '#7ee7db', hair: '#1f6f66', line: '#10202d' }
}
}
function faceByMood(mood: MbtiAvatarMood): { eye: string; brow: string; mouth: string; tilt: number } {
switch (mood) {
case 'sharp':
return { eye: 'M222 222 L242 220 M270 220 L290 222', brow: 'M218 210 L244 202 M268 202 L294 210', mouth: 'M234 256 Q256 246 278 256', tilt: -5 }
case 'warm':
return { eye: 'M222 224 Q232 230 242 224 M270 224 Q280 230 290 224', brow: 'M220 210 Q232 206 244 210 M268 210 Q280 206 292 210', mouth: 'M232 254 Q256 272 280 254', tilt: 2 }
case 'playful':
return { eye: 'M222 224 Q232 236 242 224 M270 224 Q280 236 290 224', brow: 'M220 210 Q234 200 246 208 M266 208 Q278 200 292 210', mouth: 'M232 256 Q256 266 280 250', tilt: 8 }
default:
return { eye: 'M222 224 Q232 220 242 224 M270 224 Q280 220 290 224', brow: 'M220 210 Q232 208 244 210 M268 210 Q280 208 292 210', mouth: 'M236 256 Q256 260 276 256', tilt: 0 }
}
}
function shouldersByMood(mood: MbtiAvatarMood): string {
switch (mood) {
case 'sharp':
return 'M168 370 L206 300 L256 332 L306 300 L344 370 L306 392 L256 374 L206 392 Z'
case 'warm':
return 'M166 368 Q188 318 226 314 L256 340 L286 314 Q324 318 346 368 L314 392 Q286 404 256 396 Q226 404 198 392 Z'
case 'playful':
return 'M164 370 L198 304 L252 332 L318 300 L350 374 L316 394 L258 378 L196 396 Z'
default:
return 'M166 370 L202 306 L256 336 L310 306 L346 370 L310 392 L256 380 L202 392 Z'
}
}
export function buildMbtiSvgAvatarDataUrl(type: MbtiType): string {
const p = MBTI_AVATAR_PROFILES[type]
const palette = paletteByGroup(p.group)
const face = faceByMood(p.mood)
const shoulder = shouldersByMood(p.mood)
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="g" cx="50%" cy="35%" r="70%">
<stop offset="0%" stop-color="#1a2a42"/>
<stop offset="100%" stop-color="${palette.bg}"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#g)"/>
<circle cx="256" cy="256" r="228" fill="none" stroke="#e6bd4f" stroke-width="8"/>
<circle cx="256" cy="256" r="214" fill="#f6f8fb"/>
<g transform="rotate(${face.tilt} 256 256)">
<path d="${shoulder}" fill="${palette.body}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<polygon points="214,190 256,164 298,190 290,250 222,250" fill="#f8e9d8" stroke="${palette.line}" stroke-width="5"/>
<path d="M206 196 L226 150 L286 150 L306 196 L286 206 L226 206 Z" fill="${palette.hair}" stroke="${palette.line}" stroke-width="5" stroke-linejoin="round"/>
<circle cx="220" cy="186" r="9" fill="${palette.hair}"/>
<circle cx="292" cy="186" r="9" fill="${palette.hair}"/>
<path d="${face.brow}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="${face.eye}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M256 228 L250 240 L262 240" fill="none" stroke="${palette.line}" stroke-width="4" stroke-linecap="round"/>
<path d="${face.mouth}" fill="none" stroke="${palette.line}" stroke-width="5" stroke-linecap="round"/>
<path d="M212 332 L256 356 L300 332" fill="none" stroke="${palette.accent}" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}

View File

@@ -44,6 +44,12 @@ interface ListRes {
error?: string
}
function confirmDangerousDelete(entity: string): boolean {
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
return verifyText === '删除'
}
export function AdminUsersPage() {
const [records, setRecords] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
@@ -72,7 +78,7 @@ export function AdminUsersPage() {
pageSize: String(pageSize),
})
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
const data = await get<ListRes>(`/api/admin/admin-users?${params}`)
const data = await get<ListRes>(`/api/admin/users?${params}`)
if (data?.success) {
setRecords((data as ListRes).records || [])
setTotal((data as ListRes).total ?? 0)
@@ -130,7 +136,7 @@ export function AdminUsersPage() {
setSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
id: editingUser.id,
password: formPassword || undefined,
name: formName.trim(),
@@ -144,7 +150,7 @@ export function AdminUsersPage() {
setError(data?.error || '保存失败')
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
username: formUsername.trim(),
password: formPassword,
name: formName.trim(),
@@ -166,9 +172,12 @@ export function AdminUsersPage() {
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该管理员')) return
if (!confirmDangerousDelete('管理员')) {
setError('已取消删除')
return
}
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`)
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
if (data?.success) loadList()
else setError(data?.error || '删除失败')
} catch (e: unknown) {

View File

@@ -260,8 +260,9 @@ export function ContentPage() {
label: '',
aliases: '',
url: '',
type: 'url' as 'url' | 'miniprogram' | 'ckb',
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: '',
appSecret: '',
pagePath: '',
})
const [linkTagSaving, setLinkTagSaving] = useState(false)
@@ -537,7 +538,15 @@ export function ContentPage() {
try {
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
}>('/api/db/link-tags')
if (data?.success && data.linkTags) {
setLinkTags(
@@ -545,9 +554,10 @@ export function ContentPage() {
id: t.tagId,
label: t.label,
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
}
@@ -608,7 +618,16 @@ export function ContentPage() {
if (s) qs.set('search', s)
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; aliases?: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
aliases?: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
total?: number
page?: number
pageSize?: number
@@ -622,9 +641,10 @@ export function ContentPage() {
label: t.label,
aliases: t.aliases || '',
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
@@ -2810,7 +2830,7 @@ export function ContentPage() {
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
onClick={() => {
setLinkTagEditing(null)
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', appSecret: '', pagePath: '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
setLinkTagModalOpen(true)
@@ -2862,6 +2882,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2882,12 +2903,12 @@ export function ContentPage() {
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: t.type === 'miniprogram'
: t.type === 'miniprogram' || t.type === 'wxlink'
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : t.type === 'wxlink' ? '小程序链接' : '小程序'}
</Badge>
</td>
<td className="px-3 py-2 text-gray-300">
@@ -2903,6 +2924,18 @@ export function ContentPage() {
)
})()}
{t.pagePath && <div className="text-xs text-gray-500 font-mono">{t.pagePath}</div>}
<div
className={`text-xs ${t.hasAppSecret ? 'text-emerald-400/90' : 'text-amber-500/80'}`}
>
AppSecret{t.hasAppSecret ? '已保存(仅服务端)' : '未配置'}
</div>
</div>
) : t.type === 'wxlink' ? (
<div className="space-y-0.5">
<div className="text-xs text-[#38bdac] truncate max-w-[420px] font-mono" title={t.url}>
{t.url || '—'}
</div>
<div className="text-[11px] text-gray-500"> web-view </div>
</div>
) : t.url ? (
<a
@@ -2932,6 +2965,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2996,7 +3030,7 @@ export function ContentPage() {
<DialogHeader className="gap-1">
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
<DialogDescription className="text-gray-400 text-xs">
#
# mpKey AppIDAppSecret 使
</DialogDescription>
</DialogHeader>
@@ -3006,7 +3040,7 @@ export function ContentPage() {
<Label className="text-gray-300 text-sm">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="留空自动生成;或填 12位数字 / z开头12位"
placeholder="留空自动生成;或自定义短 ID如 kr最长 50 字符"
value={linkTagForm.tagId}
disabled={!!linkTagEditing}
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))}
@@ -3038,7 +3072,7 @@ export function ContentPage() {
<Select
value={linkTagForm.type}
onValueChange={(v) =>
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' }))
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' }))
}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
@@ -3046,7 +3080,8 @@ export function ContentPage() {
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="miniprogram">API跳转</SelectItem>
<SelectItem value="wxlink"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
@@ -3057,9 +3092,18 @@ export function ContentPage() {
? 'URL地址'
: linkTagForm.type === 'ckb'
? '存客宝计划URL'
: '小程序(选密钥)'}
: linkTagForm.type === 'wxlink'
? '小程序链接'
: '小程序 mpKey / 微信 AppID'}
</Label>
{linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
{linkTagForm.type === 'wxlink' ? (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="粘贴小程序右上角 ... → 复制链接 得到的 URL"
value={linkTagForm.url}
onChange={(e) => setLinkTagForm((p) => ({ ...p, url: e.target.value }))}
/>
) : linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
@@ -3110,7 +3154,7 @@ export function ContentPage() {
? 'https://...'
: linkTagForm.type === 'ckb'
? 'https://ckbapi.quwanzhi.com/...'
: '关联小程序的32位密钥'
: '关联配置的 key或直接填 wx 开头的 AppID'
}
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
onChange={(e) => {
@@ -3123,16 +3167,38 @@ export function ContentPage() {
</div>
</div>
{linkTagForm.type === 'wxlink' && (
<p className="text-[11px] text-amber-400/80 leading-snug px-0.5">
... web-view
</p>
)}
{linkTagForm.type === 'miniprogram' && (
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<>
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm">AppSecret · </Label>
<Input
type="password"
autoComplete="new-password"
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder={linkTagEditing?.hasAppSecret ? '已保存密钥,留空不改;填写则覆盖' : '粘贴目标小程序 AppSecret'}
value={linkTagForm.appSecret}
onChange={(e) => setLinkTagForm((p) => ({ ...p, appSecret: e.target.value }))}
/>
<p className="text-[11px] text-gray-500 leading-snug">
AppID
</p>
</div>
</>
)}
</div>
@@ -3149,13 +3215,18 @@ export function ContentPage() {
url: linkTagForm.url.trim(),
type: linkTagForm.type,
appId: linkTagForm.appId.trim(),
appSecret: linkTagForm.appSecret.trim(),
pagePath: linkTagForm.pagePath.trim(),
}
// 新增:允许留空后端自动生成;编辑tagId 已锁定
// 留空后端自动生成;自定义时与库一致150 字符,勿含 #、逗号、换行
if (payload.tagId) {
const ok = /^\d{12}$/.test(payload.tagId) || /^z[a-z0-9]{11}$/.test(payload.tagId)
if (!ok) {
toast.error('标签ID需为12位数字或 z 开头的12位z+11位小写字母数字')
const id = payload.tagId
if ([...id].length > 50) {
toast.error('标签ID 最长 50 个字符')
return
}
if (/[#,\n\r\t]/.test(id)) {
toast.error('标签ID 不能含 #、逗号或换行')
return
}
}
@@ -3164,6 +3235,7 @@ export function ContentPage() {
return
}
if (payload.type === 'miniprogram') payload.url = ''
if (payload.type === 'wxlink') { payload.appId = ''; payload.pagePath = '' }
setLinkTagSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)

View File

@@ -50,6 +50,32 @@ interface DashboardOverviewRes {
newUsers?: UserRow[]
}
interface MatchStatsRes {
success?: boolean
data?: {
totalMatches?: number
todayMatches?: number
uniqueUsers?: number
paidMatchCount?: number
}
}
interface DistributionOverviewRes {
success?: boolean
overview?: {
todayClicks?: number
todayBindings?: number
todayConversions?: number
monthClicks?: number
monthBindings?: number
monthConversions?: number
totalClicks?: number
totalBindings?: number
totalConversions?: number
conversionRate?: string
}
}
interface UsersRes {
success?: boolean
users?: UserRow[]
@@ -62,6 +88,13 @@ interface OrdersRes {
total?: number
}
interface VipMemberLite {
id: string
name?: string
nickname?: string
token?: string
}
export function DashboardPage() {
const navigate = useNavigate()
const [statsLoading, setStatsLoading] = useState(true)
@@ -84,12 +117,32 @@ export function DashboardPage() {
Array<{ userId: string; nickname?: string; avatar?: string; phone?: string; clicks: number; uniqueClicks: number; leadCount?: number }>
>([])
const [superLoading, setSuperLoading] = useState(false)
const [trackPeriod, setTrackPeriod] = useState<string>('week')
const [trackPeriod, setTrackPeriod] = useState<string>('today')
const [trackStats, setTrackStats] = useState<{
total: number
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
} | null>(null)
const [trackLoading, setTrackLoading] = useState(false)
const [partnerPromoLoading, setPartnerPromoLoading] = useState(true)
const [matchStats, setMatchStats] = useState<{
totalMatches: number
todayMatches: number
uniqueUsers: number
paidMatchCount: number
} | null>(null)
const [distributionOverview, setDistributionOverview] = useState<{
todayClicks: number
todayBindings: number
todayConversions: number
monthClicks: number
monthBindings: number
monthConversions: number
totalClicks: number
totalBindings: number
totalConversions: number
conversionRate?: string
} | null>(null)
const [vipMembers, setVipMembers] = useState<VipMemberLite[]>([])
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
@@ -153,6 +206,60 @@ export function DashboardPage() {
setCkbStats(null)
}
// 加载「找伙伴 × 推广中心」共统计
setPartnerPromoLoading(true)
try {
const [matchRes, distRes] = await Promise.allSettled([
get<MatchStatsRes>('/api/db/match-records?stats=true', init),
get<DistributionOverviewRes>('/api/admin/distribution/overview', init),
])
if (matchRes.status === 'fulfilled' && matchRes.value?.success && matchRes.value.data) {
setMatchStats({
totalMatches: matchRes.value.data.totalMatches ?? 0,
todayMatches: matchRes.value.data.todayMatches ?? 0,
uniqueUsers: matchRes.value.data.uniqueUsers ?? 0,
paidMatchCount: matchRes.value.data.paidMatchCount ?? 0,
})
} else {
setMatchStats(null)
}
if (distRes.status === 'fulfilled' && distRes.value?.success && distRes.value.overview) {
setDistributionOverview({
todayClicks: distRes.value.overview.todayClicks ?? 0,
todayBindings: distRes.value.overview.todayBindings ?? 0,
todayConversions: distRes.value.overview.todayConversions ?? 0,
monthClicks: distRes.value.overview.monthClicks ?? 0,
monthBindings: distRes.value.overview.monthBindings ?? 0,
monthConversions: distRes.value.overview.monthConversions ?? 0,
totalClicks: distRes.value.overview.totalClicks ?? 0,
totalBindings: distRes.value.overview.totalBindings ?? 0,
totalConversions: distRes.value.overview.totalConversions ?? 0,
conversionRate: distRes.value.overview.conversionRate,
})
} else {
setDistributionOverview(null)
}
} catch {
setMatchStats(null)
setDistributionOverview(null)
} finally {
setPartnerPromoLoading(false)
}
// 加载超级个体名单(用于点击统计里把 ID 显示为名字)
try {
const vipRes = await get<{ success?: boolean; data?: VipMemberLite[] }>('/api/db/vip-members?limit=500', init)
if (vipRes?.success && Array.isArray(vipRes.data)) {
setVipMembers(vipRes.data)
} else {
setVipMembers([])
}
} catch {
setVipMembers([])
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
@@ -220,6 +327,130 @@ export function DashboardPage() {
}
}
const moduleLabels: Record<string, string> = {
home: '首页',
chapters: '目录',
read: '阅读页',
my: '我的',
vip: '超级个体',
wallet: '钱包',
match: '找伙伴',
referral: '推广中心',
search: '搜索',
settings: '设置',
about: '关于',
member_detail: '成员详情',
other: '其他',
}
const actionLabels: Record<string, string> = {
btn_click: '按钮点击',
nav_click: '导航点击',
card_click: '卡片点击',
tab_click: '标签切换',
page_view: '页面浏览',
share: '分享',
purchase: '购买',
register: '注册',
rule_trigger: '规则触发',
view_chapter: '浏览章节',
link_click: '链接点击',
}
const normalizeTrackToken = (value?: string) => {
if (!value) return ''
return value
.replace(/^part-/, '')
.replace(/^soulvip_/, '')
.replace(/^super_?/, '')
.replace(/^user_/, '')
.replace(/[_-]+/g, ' ')
.trim()
}
const resolveVipNameByToken = (value?: string) => {
if (!value) return ''
const key = value.trim().toLowerCase()
if (!key) return ''
const byId = vipMembers.find((m) => {
const id = String(m.id || '').toLowerCase()
return id === key || id.includes(key) || key.includes(id)
})
if (byId) return byId.name || byId.nickname || ''
const byToken = vipMembers.find((m) => {
const t = String(m.token || '').toLowerCase()
return t && (t === key || t.includes(key) || key.includes(t))
})
if (byToken) return byToken.name || byToken.nickname || ''
return ''
}
const prettyTrackTarget = (target?: string) => {
if (!target) return '未命名点击'
const t = target.trim()
const lower = t.toLowerCase()
if (/^链接头像[_-]/.test(t)) {
const rawName = normalizeTrackToken(t.replace(/^链接头像[_-]/, ''))
return rawName ? `头像:${rawName}` : '头像点击'
}
if (/^member[_-]?detail$/i.test(lower) || lower.includes('member detail')) return '成员详情'
if (/^giftpay$/i.test(lower) || lower.includes('gift pay')) return '代付入口'
if (/^part[-_]/i.test(lower)) return `章节:${normalizeTrackToken(t)}`
if (lower.includes('soulvip') || lower.includes('super')) {
const raw = t
.replace(/^超级个体[:]?/i, '')
.replace(/^super[_-]?/i, '')
.replace(/^soulvip[_-]?/i, '')
.replace(/^user[_-]?/i, '')
.trim()
const vipName = resolveVipNameByToken(raw) || resolveVipNameByToken(normalizeTrackToken(raw))
if (vipName) return `超级个体:${vipName}`
return `超级个体:${normalizeTrackToken(raw)}`
}
if (lower.includes('qgdtw') || lower.includes('token') || lower.includes('0000')) return `对象:${normalizeTrackToken(t)}`
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配',
mentor: '导师顾问',
team: '团队招募',
investor: '资源对接',
'充值': '充值',
'退款': '退款',
wallet: '钱包',
'设置': '设置',
'VIP': 'VIP会员',
'推广': '推广中心',
'目录': '目录',
'搜索': '搜索',
'匹配': '找伙伴',
settings: '设置',
expired: '已过期',
active: '活跃',
converted: '已转化',
fill_profile: '完善资料',
register: '注册',
purchase: '购买',
'链接卡若': '链接卡若',
'更多分享': '更多分享',
'分享朋友圈文案': '分享朋友圈',
'选择金额10': '选择金额10元',
member_detail: '成员详情',
giftPay: '代付入口',
}
if (targetLabels[t]) return targetLabels[t]
if (/^[a-z0-9_-]+$/i.test(t)) return normalizeTrackToken(t) || t
return t
}
const buildTrackLocationLabel = (item: { module: string; page: string; action: string; target: string }) => {
const moduleName = moduleLabels[item.module] || moduleLabels[item.page] || item.module || item.page || '其他'
const actionName = actionLabels[item.action] || item.action || '点击'
const targetName = prettyTrackTarget(item.target)
return `${moduleName} · ${actionName} · ${targetName}`
}
async function loadSuperStats() {
setSuperLoading(true)
try {
@@ -342,6 +573,19 @@ export function DashboardPage() {
bg: 'bg-cyan-500/20',
link: '/users?tab=leads',
},
{
title: '伙伴&推广协同',
value: partnerPromoLoading
? null
: (matchStats?.totalMatches ?? 0) + (distributionOverview?.totalClicks ?? 0),
sub: partnerPromoLoading
? null
: `找伙伴 ${(matchStats?.totalMatches ?? 0)} / 推广 ${(distributionOverview?.totalClicks ?? 0)}`,
icon: BarChart3,
color: 'text-emerald-400',
bg: 'bg-emerald-500/20',
link: '/find-partner',
},
]
return (
@@ -359,11 +603,11 @@ export function DashboardPage() {
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="flex flex-nowrap gap-6 mb-8 overflow-x-auto pb-1">
{stats.map((stat, index) => (
<Card
key={index}
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
className="min-w-[220px] flex-1 bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
onClick={() => stat.link && navigate(stat.link)}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -433,6 +677,63 @@ export function DashboardPage() {
</div>
{bottomTab === 'overview' && (
<div className="space-y-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white"> × 广</CardTitle>
<button
type="button"
onClick={() => loadAll()}
disabled={partnerPromoLoading}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
title="刷新共统计"
>
<RefreshCw className={`w-3.5 h-3.5 ${partnerPromoLoading ? 'animate-spin' : ''}`} />
</button>
</CardHeader>
<CardContent>
{partnerPromoLoading && !matchStats && !distributionOverview ? (
<div className="flex items-center justify-center py-10 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.totalMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.todayMatches ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-white mt-1">{matchStats?.uniqueUsers ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalClicks ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalBindings ?? 0}</p>
</div>
<div className="rounded-lg bg-[#0a1628] border border-gray-700/30 p-4">
<p className="text-xs text-gray-400">广</p>
<p className="text-2xl font-bold text-white mt-1">{distributionOverview?.totalConversions ?? 0}</p>
</div>
</div>
)}
{distributionOverview?.conversionRate && (
<p className="text-xs text-gray-500 mt-3">
广{distributionOverview.conversionRate}
</p>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader className="flex flex-row items-center justify-between">
@@ -488,7 +789,7 @@ export function DashboardPage() {
<img
src={p.userAvatar}
alt={buyer}
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
onError={(e) => {
e.currentTarget.style.display = 'none'
const next = e.currentTarget.nextElementSibling as HTMLElement
@@ -497,7 +798,7 @@ export function DashboardPage() {
/>
) : null}
<div
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
>
{buyer.charAt(0)}
</div>
@@ -537,7 +838,7 @@ export function DashboardPage() {
</div>
</div>
<div className="text-right ml-4 flex-shrink-0">
<div className="text-right ml-4 shrink-0">
<p className="text-sm font-bold text-[#38bdac]">
+¥{Number(p.amount).toFixed(2)}
</p>
@@ -620,6 +921,7 @@ export function DashboardPage() {
</CardContent>
</Card>
</div>
</div>
)}
{bottomTab === 'tags' && (
@@ -663,11 +965,6 @@ export function DashboardPage() {
.slice(0, 5)
.map(([mod, items]) => {
const moduleTotal = items.reduce((s, i) => s + i.count, 0)
const moduleLabels: Record<string, string> = {
home: '首页', chapters: '目录', read: '阅读', my: '我的',
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
search: '搜索', settings: '设置', about: '关于', other: '其他',
}
return (
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
@@ -681,28 +978,10 @@ export function DashboardPage() {
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => {
const targetLabels: Record<string, string> = {
'开始匹配': '开始匹配', 'mentor': '导师顾问', 'team': '团队招募',
'investor': '资源对接', '充值': '充值', '退款': '退款',
'wallet': '钱包', '设置': '设置', 'VIP': 'VIP会员',
'推广': '推广中心', '目录': '目录', '搜索': '搜索',
'匹配': '找伙伴', 'settings': '设置', 'expired': '已过期',
'active': '活跃', 'converted': '已转化', 'fill_profile': '完善资料',
'register': '注册', 'purchase': '购买', 'btn_click': '按钮点击',
'nav_click': '导航点击', 'card_click': '卡片点击', 'tab_click': '标签切换',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
'链接卡若': '链接卡若', '更多分享': '更多分享', '分享朋友圈文案': '分享朋友圈',
'选择金额10': '选择金额10元',
}
const actionLabels: Record<string, string> = {
'btn_click': '按钮点击', 'nav_click': '导航点击', 'card_click': '卡片点击',
'tab_click': '标签切换', 'purchase': '购买', 'register': '注册',
'rule_trigger': '规则触发', 'view_chapter': '浏览章节',
}
const label = targetLabels[item.target] || item.target || actionLabels[item.action] || item.action
const label = buildTrackLocationLabel(item)
return (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
<span className="text-gray-300 truncate mr-2" title={label}>
{label}
</span>
<div className="flex items-center gap-2 shrink-0">

View File

@@ -44,6 +44,10 @@ interface Purchase {
giftPayRequestId?: string
payerUserId?: string
payerNickname?: string
webhookPushStatus?: 'sent' | 'failed' | ''
webhookPushedAt?: string
webhookPushAttempts?: number
webhookPushError?: string
}
interface UsersItem {
@@ -362,23 +366,35 @@ export function OrdersPage() {
: purchase.paymentMethod || '微信支付'}
</TableCell>
<TableCell>
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
<div className="flex items-center gap-2 flex-wrap">
{purchase.status === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
) : purchase.status === 'pending' || purchase.status === 'created' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
</Badge>
)}
{(purchase.status === 'paid' || purchase.status === 'completed') &&
(purchase.webhookPushStatus === 'sent' ? (
<Badge className="bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/20 border-0">
</Badge>
) : (
<Badge className="bg-orange-500/20 text-orange-300 hover:bg-orange-500/20 border-0">
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-gray-400 text-sm max-w-[120px] truncate" title={purchase.refundReason}>
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'}

View File

@@ -36,6 +36,10 @@ import {
Link2,
FileText,
Cloud,
Eye,
EyeOff,
LayoutGrid,
Sparkles,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
@@ -72,6 +76,8 @@ interface MpConfig {
mchId?: string
minWithdraw?: number
auditMode?: boolean
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
mpUi?: Record<string, unknown>
}
interface OssConfig {
@@ -113,17 +119,60 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
chaptersPage: {
bookTitle: '一场SOUL的创业实验场',
bookSubtitle: '来自Soul派对房的真实商业故事',
},
// homePage.linkKaruoAvatar首页「链接卡若」头像 HTTPS空则小程序用「卡」字占位
homePage: {
logoTitle: '卡若创业派对',
logoSubtitle: '来自派对房的真实故事',
linkKaruoText: '点击链接卡若',
linkKaruoAvatar: '',
searchPlaceholder: '搜索章节标题或内容...',
bannerTag: '推荐',
bannerReadMoreText: '点击阅读',
superSectionTitle: '超级个体',
superSectionLinkText: '获客入口',
superSectionLinkPath: '/pages/match/match',
pickSectionTitle: '精选推荐',
latestSectionTitle: '最新新增',
},
myPage: {
cardLabel: '名片',
vipLabelVip: '会员中心',
vipLabelGuest: '成为会员',
cardPath: '',
vipPath: '/pages/vip/vip',
readStatLabel: '已读章节',
recentReadTitle: '最近阅读',
readStatPath: '/pages/reading-records/reading-records?focus=all',
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
},
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'oss', 'features'] as const
type SystemSectionKey = (typeof SYSTEM_SECTION_KEYS)[number]
export function SettingsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') ?? 'system'
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
const systemSectionParam = searchParams.get('section') ?? 'basic'
const systemSection: SystemSectionKey = SYSTEM_SECTION_KEYS.includes(systemSectionParam as SystemSectionKey)
? (systemSectionParam as SystemSectionKey)
: 'basic'
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [mpUiJson, setMpUiJson] = useState('{}')
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
@@ -153,8 +202,18 @@ export function SettingsPage() {
if (!res || (res as { success?: boolean }).success === false) return
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object') {
const merged = { ...res.mpConfig } as MpConfig
setMpConfig((prev) => ({ ...prev, ...merged }))
const raw = merged.mpUi
setMpUiJson(
JSON.stringify(
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
null,
2,
),
)
}
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
@@ -235,6 +294,25 @@ export function SettingsPage() {
const handleSave = async () => {
setIsSaving(true)
try {
let mpUi: Record<string, unknown> = {}
try {
const t = mpUiJson.trim()
if (t) {
const parsed: unknown = JSON.parse(t)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
mpUi = parsed as Record<string, unknown>
} else {
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
setIsSaving(false)
return
}
}
} catch {
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
setIsSaving(false)
return
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
siteSettings: {
@@ -251,6 +329,7 @@ export function SettingsPage() {
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
auditMode: mpConfig.auditMode ?? false,
mpUi,
},
ossConfig: Object.keys(ossConfig).length
? {
@@ -276,7 +355,23 @@ export function SettingsPage() {
}
const handleTabChange = (v: string) => {
setSearchParams(v === 'system' ? {} : { tab: v })
if (v === 'system') {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
if (!SYSTEM_SECTION_KEYS.includes((sp.get('section') || 'basic') as SystemSectionKey)) {
sp.set('section', 'basic')
}
setSearchParams(sp)
return
}
setSearchParams({ tab: v })
}
const handleSystemSectionChange = (v: string) => {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
sp.set('section', v)
setSearchParams(sp)
}
if (loading) return <div className="p-8 text-gray-500">...</div>
@@ -337,7 +432,45 @@ export function SettingsPage() {
</TabsList>
<TabsContent value="system" className="mt-0">
<div className="space-y-6">
<p className="text-xs text-gray-500 mb-3">
MBTI {' '}
<Link to="/users" className="text-[#38bdac] underline">
</Link>
</p>
<Tabs value={systemSection} onValueChange={handleSystemSectionChange} className="w-full">
<TabsList className="mb-4 bg-[#0a1628] border border-gray-700/50 p-1 flex-wrap h-auto gap-1">
<TabsTrigger
value="basic"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="mp"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Smartphone className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="oss"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Cloud className="w-3.5 h-3.5 mr-1" />
OSS
</TabsTrigger>
<TabsTrigger
value="features"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<Settings className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -538,7 +671,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mp" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -599,9 +734,71 @@ export function SettingsPage() {
/>
</div>
</div>
<div className="space-y-2 pt-2 border-t border-gray-700/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-gray-300"> mpUiJSON</Label>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200"
onClick={() => setMpUiJson(JSON.stringify(MP_UI_TEMPLATE_OBJECT, null, 2))}
>
</Button>
</div>
<p className="text-xs text-gray-500">
Tab / 5
config
</p>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm min-h-[280px]"
spellCheck={false}
value={mpUiJson}
onChange={(e) => setMpUiJson(e.target.value)}
/>
</div>
</CardContent>
</Card>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="oss" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -673,42 +870,9 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</TabsContent>
<Card className={`bg-[#0f2137] shadow-xl ${mpConfig.auditMode ? 'border-amber-500/50 border-2' : 'border-gray-700/50'}`}>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className={`flex items-center justify-between p-4 rounded-lg border ${mpConfig.auditMode ? 'bg-amber-500/10 border-amber-500/30' : 'bg-[#0a1628] border-gray-700/50'}`}>
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className={`w-4 h-4 ${mpConfig.auditMode ? 'text-amber-400' : 'text-gray-400'}`} />
<Label htmlFor="audit-mode" className="text-white font-medium cursor-pointer">
{mpConfig.auditMode ? '审核模式(已开启)' : '审核模式(已关闭)'}
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
{mpConfig.auditMode
? '当前已隐藏所有支付、VIP、充值、收益等入口审核员看不到任何付费内容'
: '关闭状态小程序正常显示所有功能含支付、VIP 等)'}
</p>
</div>
<Switch
id="audit-mode"
checked={mpConfig.auditMode ?? false}
disabled={auditModeSaving}
onCheckedChange={handleAuditModeSwitch}
/>
</div>
</CardContent>
</Card>
<TabsContent value="features" className="space-y-6 mt-0">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
@@ -804,7 +968,41 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-xs">
{[
{ mod: '找伙伴', ctrl: '找伙伴功能开关', icon: <Users className="w-3 h-3" /> },
{ mod: '推广中心 / 推荐好友', ctrl: '推广功能开关', icon: <Gift className="w-3 h-3" /> },
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Sparkles className="w-3 h-3" /> },
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
].map((r) => (
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
{r.icon}
<div>
<span className="text-white">{r.mod}</span>
<span className="text-gray-500 ml-1"> {r.ctrl}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="author" className="mt-0">

View File

@@ -48,6 +48,7 @@ import {
UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { MbtiAvatarsManager } from '@/components/modules/mbti/MbtiAvatarsManager'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { useSearchParams } from 'react-router-dom'
@@ -62,6 +63,7 @@ interface User {
avatar?: string | null
isAdmin?: boolean | number
hasFullBook?: boolean | number
purchasedSectionCount?: number
referralCode?: string
earnings: number | string
pendingEarnings?: number | string
@@ -83,17 +85,73 @@ interface UserRule {
title: string
description: string
trigger: string
triggerConditions?: string[]
actionType?: string
actionConfig?: Record<string, unknown>
sort: number
enabled: boolean
createdAt?: string
}
const TRIGGER_OPTIONS: { value: string; label: string; group: string }[] = [
{ value: 'after_login', label: '注册/登录成功', group: '用户状态' },
{ value: 'bind_phone', label: '绑定手机号', group: '用户状态' },
{ value: 'update_avatar', label: '完善头像(非默认图,与昵称分开配置)', group: '用户状态' },
{ value: 'update_nickname', label: '修改昵称(非默认微信昵称,与头像分开)', group: '用户状态' },
{ value: 'fill_profile', label: '完善资料MBTI/行业/职位,不含头像昵称)', group: '用户状态' },
{ value: 'view_chapter', label: '浏览章节', group: '阅读行为' },
{ value: 'browse_5_chapters', label: '累计浏览5个章节', group: '阅读行为' },
{ value: 'purchase_section', label: '购买单章', group: '付费行为' },
{ value: 'purchase_fullbook', label: '购买全书/VIP', group: '付费行为' },
{ value: 'after_pay', label: '任意付款成功', group: '付费行为' },
{ value: 'after_match', label: '完成派对匹配', group: '社交行为' },
{ value: 'click_super_individual', label: '点击超级个体头像', group: '社交行为' },
{ value: 'lead_submit', label: '提交留资/链接', group: '社交行为' },
{ value: 'referral_bind', label: '被推荐人绑定', group: '分销行为' },
{ value: 'share_action', label: '分享给好友/朋友圈', group: '分销行为' },
{ value: 'withdraw_request', label: '申请提现', group: '分销行为' },
{ value: 'add_wechat', label: '添加微信联系方式', group: '用户状态' },
]
const ACTION_TYPE_OPTIONS: { value: string; label: string; desc: string }[] = [
{ value: 'popup', label: '弹窗提示', desc: '在小程序内弹窗引导用户完成下一步' },
{ value: 'navigate', label: '跳转页面', desc: '引导用户跳转到指定页面' },
{ value: 'webhook', label: '推送飞书群', desc: '触发后推送消息到飞书群Webhook' },
{ value: 'tag', label: '自动打标签', desc: '触发后自动给用户打上指定标签' },
]
/** 后端曾将 []byte 编成 base64或历史脏数据。统一为 string[],避免规则列表 .map 白屏 */
function normalizeTriggerConditions(v: unknown): string[] {
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string')
if (typeof v === 'string') {
try {
const p = JSON.parse(v) as unknown
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
} catch {
try {
const decoded = typeof atob === 'function' ? atob(v) : ''
const p = JSON.parse(decoded) as unknown
if (Array.isArray(p)) return p.filter((x): x is string => typeof x === 'string')
} catch {
/* ignore */
}
}
}
return []
}
function normalizeUserRule(r: UserRule): UserRule {
return { ...r, triggerConditions: normalizeTriggerConditions(r.triggerConditions) }
}
interface VipMember {
id: string
name: string
avatar?: string | null
mbti?: string | null
vipRole?: string | null
vipSort?: number | null
webhookUrl?: string | null
/** 首页超级个体卡片点击次数(/api/db/vip-members 聚合 user_tracks */
clickCount?: number | null
/** 绑定人物后的去重获客人数 */
@@ -121,10 +179,17 @@ const JOURNEY_STAGES = [
{ id: 'distribution', label: '开启分销', icon: '🔗', color: 'bg-[#38bdac]/20 border-[#38bdac]/40 text-[#38bdac]', desc: '生成推广码并推荐好友' },
]
function confirmDangerousDelete(entity: string): boolean {
if (!confirm(`确定删除该${entity}?此操作不可恢复。`)) return false
const verifyText = window.prompt(`请输入「删除」以确认删除${entity}`)
return verifyText === '删除'
}
export function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams()
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
const tabParam = searchParams.get('tab') || 'users' // users | journey | rules | vip-roles | leads
const rawTabParam = searchParams.get('tab') || 'users'
const tabParam = ['users', 'journey', 'rules', 'vip-roles', 'leads'].includes(rawTabParam) ? rawTabParam : 'users'
// ===== 用户列表 state =====
const [users, setUsers] = useState<User[]>([])
@@ -159,6 +224,7 @@ export function UsersPage() {
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
const [showMbtiAvatarDialog, setShowMbtiAvatarDialog] = useState(false)
const [formData, setFormData] = useState({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false })
// ===== 规则管理 =====
@@ -166,7 +232,7 @@ export function UsersPage() {
const [rulesLoading, setRulesLoading] = useState(false)
const [showRuleModal, setShowRuleModal] = useState(false)
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', triggerConditions: [] as string[], actionType: 'popup', sort: 0, enabled: true })
// ===== 超级个体VIP 用户列表) =====
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
@@ -177,6 +243,14 @@ export function UsersPage() {
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
const [journeyStage, setJourneyStage] = useState<string | null>(null)
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([])
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
const [trackUserId, setTrackUserId] = useState<string | null>(null)
const [trackUserNick, setTrackUserNick] = useState('')
const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([])
const [userTracksLoading, setUserTracksLoading] = useState(false)
const [mbtiAvatarsMap, setMbtiAvatarsMap] = useState<Record<string, string>>({})
// ===== 获客列表(存客宝) =====
const [leadsRecords, setLeadsRecords] = useState<{
@@ -239,10 +313,56 @@ export function UsersPage() {
setLeadsLoading(false)
}
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter])
const loadMbtiAvatarsMap = useCallback(async () => {
try {
const data = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
const map = (data?.avatars && typeof data.avatars === 'object') ? data.avatars : {}
setMbtiAvatarsMap(map)
} catch {
setMbtiAvatarsMap({})
}
}, [])
useEffect(() => {
if (searchParams.get('tab') === 'leads') loadLeads()
}, [searchParams.get('tab'), leadsPage, loadLeads])
useEffect(() => {
loadMbtiAvatarsMap()
}, [loadMbtiAvatarsMap])
const resolveUserAvatarByMbti = useCallback((avatar: string | null | undefined, mbti: string | null | undefined): string => {
const av = (avatar || '').trim()
if (av) return av
const key = (mbti || '').trim().toUpperCase()
if (!/^[EI][NS][FT][JP]$/.test(key)) return ''
return (mbtiAvatarsMap[key] || '').trim()
}, [mbtiAvatarsMap])
const getPurchaseState = useCallback((user: User) => {
const hasFull = !!user.hasFullBook
const sectionCount = Number(user.purchasedSectionCount || 0)
if (hasFull) {
return {
tone: 'vip' as const,
main: '已购全书',
sub: sectionCount > 0 ? `另购单章 ${sectionCount}` : '购买项VIP / 全书',
}
}
if (sectionCount > 0) {
return {
tone: 'paid' as const,
main: `已购 ${sectionCount}`,
sub: '购买项:章节',
}
}
return {
tone: 'free' as const,
main: '未购买',
sub: '',
}
}, [])
// ===== 在线人数WSS 占位) =====
const [onlineCount, setOnlineCount] = useState<number | null>(null)
const loadOnlineStats = useCallback(async () => {
@@ -337,7 +457,10 @@ export function UsersPage() {
}
async function handleDelete(userId: string) {
if (!confirm('确定要删除这个用户吗?')) return
if (!confirmDangerousDelete('用户')) {
toast.info('已取消删除')
return
}
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
if (data?.success) {
@@ -395,7 +518,7 @@ export function UsersPage() {
setRulesLoading(true)
try {
const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules')
if (data?.success) setRules(data.rules || [])
if (data?.success) setRules((data.rules || []).map((r) => normalizeUserRule(r)))
} catch { } finally { setRulesLoading(false) }
}, [])
@@ -415,7 +538,10 @@ export function UsersPage() {
}
async function handleDeleteRule(id: number) {
if (!confirm('确定删除?')) return
if (!confirmDangerousDelete('规则')) {
toast.info('已取消删除')
return
}
try {
const data = await del<{ success?: boolean }>(`/api/db/user-rules?id=${id}`)
if (data?.success) loadRules()
@@ -454,6 +580,10 @@ export function UsersPage() {
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
const [vipRoleInput, setVipRoleInput] = useState('')
const [vipRoleSaving, setVipRoleSaving] = useState(false)
const [showVipWebhookModal, setShowVipWebhookModal] = useState(false)
const [vipWebhookModalMember, setVipWebhookModalMember] = useState<VipMember | null>(null)
const [vipWebhookInput, setVipWebhookInput] = useState('')
const [vipWebhookSaving, setVipWebhookSaving] = useState(false)
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
@@ -463,6 +593,12 @@ export function UsersPage() {
setShowVipRoleModal(true)
}
const openVipWebhookModal = (member: VipMember) => {
setVipWebhookModalMember(member)
setVipWebhookInput((member.webhookUrl || '').trim())
setShowVipWebhookModal(true)
}
const handleSetVipRole = async (value: string) => {
const trimmed = value.trim()
if (!vipRoleModalMember) return
@@ -491,6 +627,34 @@ export function UsersPage() {
}
}
const handleSetVipWebhook = async () => {
if (!vipWebhookModalMember) return
const val = vipWebhookInput.trim()
if (val && !/^https?:\/\//i.test(val)) {
toast.error('Webhook 地址需以 http/https 开头')
return
}
setVipWebhookSaving(true)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/vip-members/webhook', {
userId: vipWebhookModalMember.id,
webhookUrl: val,
})
if (!res?.success) {
toast.error(res?.error || '保存飞书群 Webhook 失败')
return
}
toast.success(val ? '已保存该超级个体的飞书群 Webhook' : '已清空该超级个体的飞书群 Webhook')
setShowVipWebhookModal(false)
setVipWebhookModalMember(null)
await loadVipMembers()
} catch {
toast.error('保存飞书群 Webhook 失败')
} finally {
setVipWebhookSaving(false)
}
}
const [showVipSortModal, setShowVipSortModal] = useState(false)
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
const [vipSortInput, setVipSortInput] = useState('')
@@ -599,6 +763,23 @@ export function UsersPage() {
if (data?.success && data.stats) setJourneyStats(data.stats)
} catch { } finally { setJourneyLoading(false) }
}, [])
const loadJourneyUsers = useCallback(async (stage: string) => {
setJourneyStage(stage)
setJourneyUsersLoading(true)
try {
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`)
if (data?.success && data.users) setJourneyUsers(data.users)
} catch { } finally { setJourneyUsersLoading(false) }
}, [])
const loadUserTracks = useCallback(async (userId: string, nick: string) => {
setTrackUserId(userId)
setTrackUserNick(nick)
setUserTracksLoading(true)
try {
const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`)
if (data?.success && data.tracks) setUserTracks(data.tracks)
} catch { } finally { setUserTracksLoading(false) }
}, [])
// ===== 批量用户补全 =====
const [batchEnrichLoading, setBatchEnrichLoading] = useState(false)
@@ -855,34 +1036,48 @@ export function UsersPage() {
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden">
{user.avatar ? (
<img
src={user.avatar}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) parent.textContent = user.nickname?.charAt(0) || '?'
}}
/>
) : user.nickname?.charAt(0) || '?'}
</div>
<div>
{(() => {
const avatarUrl = resolveUserAvatarByMbti(user.avatar, user.mbti)
const initial = user.nickname?.charAt(0) || '?'
return (
<button
type="button"
title="点击管理 MBTI 默认头像库"
onClick={() => setShowMbtiAvatarDialog(true)}
className="w-10 h-10 shrink-0 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] overflow-hidden ring-1 ring-transparent hover:ring-[#38bdac]/60 transition"
>
{avatarUrl ? (
<img
src={avatarUrl}
className="w-full h-full rounded-full object-cover"
alt=""
onError={(e) => {
const img = e.target as HTMLImageElement
img.style.display = 'none'
if (img.nextElementSibling) return
const span = document.createElement('span')
span.textContent = initial
img.parentElement?.appendChild(span)
}}
/>
) : initial}
</button>
)
})()}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }}
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
className="font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left truncate max-w-[120px]"
>
{user.nickname}
</button>
{user.isAdmin && <Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs"></Badge>}
{user.openId && !user.id?.startsWith('user_') && <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs"></Badge>}
</div>
<p className="text-xs text-gray-500 font-mono">
{user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)}
<p className="text-xs text-gray-500 font-mono truncate max-w-[140px]" title={user.id}>
{user.id?.slice(0, 16)}{(user.id?.length ?? 0) > 16 ? '…' : ''}
</p>
</div>
</div>
@@ -891,16 +1086,38 @@ export function UsersPage() {
<div className="space-y-1">
{user.phone && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">📱</span><span className="text-gray-300">{user.phone}</span></div>}
{user.wechatId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">💬</span><span className="text-gray-300">{user.wechatId}</span></div>}
{user.openId && <div className="flex items-center gap-1 text-xs"><span className="text-gray-500">🔗</span><span className="text-gray-500 truncate max-w-[100px]" title={user.openId}>{user.openId.slice(0, 12)}...</span></div>}
{!user.phone && !user.wechatId && !user.openId && <span className="text-gray-600 text-xs"></span>}
{!user.phone && !user.wechatId && <span className="text-gray-600 text-xs"></span>}
</div>
</TableCell>
<TableCell>
{user.hasFullBook ? (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">VIP</Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>
)}
{(() => {
const purchase = getPurchaseState(user)
if (purchase.tone === 'vip') {
return (
<div className="space-y-1">
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-amber-300/80">{purchase.sub}</p>}
</div>
)
}
if (purchase.tone === 'paid') {
return (
<div className="space-y-1">
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
{purchase.main}
</Badge>
{purchase.sub && <p className="text-[11px] text-blue-300/80">{purchase.sub}</p>}
</div>
)
}
return (
<Badge variant="outline" className="text-gray-500 border-gray-600">
{purchase.main}
</Badge>
)
})()}
</TableCell>
<TableCell>
<div className="space-y-1">
@@ -915,15 +1132,13 @@ export function UsersPage() {
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
{user.rfmScore != null && user.rfmScore !== undefined ? (
<div className="flex items-center gap-1.5">
<span className="text-white font-bold text-base">{user.rfmScore}</span>
<Badge className={`border-0 text-xs ${getRFMLevelColor(user.rfmLevel)}`}>{user.rfmLevel}</Badge>
</div>
) : (
<span className="text-gray-600 text-sm"> <span className="text-xs text-gray-700"></span></span>
<span className="text-gray-600 text-xs"></span>
)}
</TableCell>
<TableCell>
@@ -1124,13 +1339,8 @@ export function UsersPage() {
<div key={stage.id} className="relative flex flex-col items-center">
{/* 阶段卡片 */}
<div
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity`}
onClick={() => {
const sp = new URLSearchParams(searchParams)
sp.delete('tab')
sp.set('search', stage.label)
setSearchParams(sp)
}}
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity ${journeyStage === stage.id ? 'ring-2 ring-[#38bdac]' : ''}`}
onClick={() => loadJourneyUsers(stage.id)}
title={`点击查看「${stage.label}」阶段的用户`}
>
<div className="text-2xl mb-1">{stage.icon}</div>
@@ -1167,7 +1377,7 @@ export function UsersPage() {
</div>
<div className="space-y-2 text-sm">
{[
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '引导填写头像' },
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '提示设置头像与昵称' },
{ step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' },
{ step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' },
{ step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' },
@@ -1217,17 +1427,100 @@ export function UsersPage() {
)}
</div>
</div>
{/* 阶段用户列表(点击阶段卡片后展开) */}
{journeyStage && (
<div className="mt-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#38bdac]" />
<span className="text-white font-medium">
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.icon}{' '}
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.label}
</span>
<Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{journeyUsers.length} </Badge>
</div>
<Button variant="ghost" size="sm" onClick={() => setJourneyStage(null)} className="text-gray-400 hover:text-white"><X className="w-4 h-4" /></Button>
</div>
{journeyUsersLoading ? (
<div className="flex items-center justify-center py-8"><RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" /></div>
) : journeyUsers.length === 0 ? (
<p className="text-gray-500 text-center py-6"></p>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{journeyUsers.map(u => (
<TableRow key={u.id} className="border-gray-700/50 hover:bg-[#0a1628]">
<TableCell className="text-white">{u.nickname || '微信用户'}</TableCell>
<TableCell className="text-gray-300">{u.phone || '-'}</TableCell>
<TableCell className="text-gray-400 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleString('zh-CN') : '-'}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="text-[#38bdac] hover:bg-[#38bdac]/10" onClick={() => loadUserTracks(u.id, u.nickname || '微信用户')}>
<Eye className="w-4 h-4 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
{/* 用户行为轨迹弹窗 */}
<Dialog open={!!trackUserId} onOpenChange={(open) => { if (!open) setTrackUserId(null) }}>
<DialogContent className="sm:max-w-[600px] bg-[#0f2137] border-gray-700 text-white max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Navigation className="w-5 h-5 text-[#38bdac]" />
{trackUserNick}
</DialogTitle>
</DialogHeader>
{userTracksLoading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
) : userTracks.length === 0 ? (
<p className="text-gray-500 text-center py-8"></p>
) : (
<div className="relative pl-6 space-y-0">
<div className="absolute left-[11px] top-2 bottom-2 w-0.5 bg-gray-700" />
{userTracks.map((t, idx) => (
<div key={t.id || idx} className="relative flex items-start gap-3 py-2">
<div className="absolute left-[-13px] top-3 w-2.5 h-2.5 rounded-full bg-[#38bdac] border-2 border-[#0f2137] z-10" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-white text-sm font-medium">{t.actionLabel}</span>
{t.module && <Badge className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[10px]">{t.module}</Badge>}
</div>
{(t.chapterTitle || t.target) && (
<p className="text-gray-400 text-xs mt-0.5 truncate">{t.chapterTitle || t.target}</p>
)}
<p className="text-gray-600 text-[10px] mt-0.5">{t.timeAgo} · {t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : ''}</p>
</div>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
</TabsContent>
{/* ===== 规则配置 ===== */}
<TabsContent value="rules">
<div className="mb-4 flex items-center justify-between">
<p className="text-gray-400 text-sm"></p>
<p className="text-gray-400 text-sm"></p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Button onClick={() => { setEditingRule(null); setRuleForm({ title: '', description: '', trigger: '', triggerConditions: [], actionType: 'popup', sort: 0, enabled: true }); setShowRuleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
@@ -1243,26 +1536,49 @@ export function UsersPage() {
</div>
) : (
<div className="space-y-2">
{rules.map((rule) => (
<div key={rule.id} className={`p-4 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<PenLine className="w-4 h-4 text-[#38bdac] shrink-0" />
<span className="text-white font-medium">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{rule.trigger}</Badge>}
<Badge className={`text-xs border-0 ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>{rule.enabled ? '启用' : '禁用'}</Badge>
</div>
{rule.description && <p className="text-gray-400 text-sm ml-6">{rule.description}</p>}
{rules.map((rule) => {
const triggerList = normalizeTriggerConditions(rule.triggerConditions)
return (
<div key={rule.id} className={`p-3 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-gray-600 text-xs font-mono w-5 shrink-0 text-right">#{rule.sort}</span>
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
{triggerList.length > 0 && (
<div className="flex flex-wrap gap-0.5 ml-1">
{triggerList.slice(0, 3).map((tc) => {
const opt = TRIGGER_OPTIONS.find((o) => o.value === tc)
return <Badge key={tc} className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[9px]">{opt?.label || tc}</Badge>
})}
{triggerList.length > 3 && <span className="text-gray-500 text-[9px]">+{triggerList.length - 3}</span>}
</div>
)}
{rule.actionType && rule.actionType !== 'popup' && (
<Badge className="bg-amber-500/10 text-amber-400 border border-amber-500/30 text-[9px] shrink-0">
{ACTION_TYPE_OPTIONS.find((o) => o.value === rule.actionType)?.label || rule.actionType}
</Badge>
)}
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<div className="flex items-center gap-1.5 ml-3 shrink-0">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, triggerConditions: triggerList, actionType: rule.actionType || 'popup', sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
</div>
{rule.description && (
<details className="ml-[52px] mt-1">
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none">
<span className="text-gray-600 ml-1">{rule.description.length} </span>
</summary>
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700 whitespace-pre-wrap">{rule.description}</p>
</details>
)}
</div>
))}
)
})}
</div>
)}
</TabsContent>
@@ -1312,9 +1628,10 @@ export function UsersPage() {
<TableHead className="text-gray-400 w-12"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 min-w-40"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-16 text-center"></TableHead>
<TableHead className="text-gray-400 w-20"></TableHead>
<TableHead className="text-gray-400 w-36"></TableHead>
<TableHead className="text-gray-400 w-36 text-right"></TableHead>
</TableRow>
</TableHeader>
@@ -1337,9 +1654,9 @@ export function UsersPage() {
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
{resolveUserAvatarByMbti(m.avatar, m.mbti) ? (
<img
src={m.avatar}
src={resolveUserAvatarByMbti(m.avatar, m.mbti)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
alt=""
onError={(e) => {
@@ -1376,6 +1693,15 @@ export function UsersPage() {
<TableCell className="text-gray-300">
{m.vipSort ?? index + 1}
</TableCell>
<TableCell className="text-xs">
{m.webhookUrl ? (
<span className="text-[#38bdac] truncate block max-w-[180px]" title={m.webhookUrl}>
</span>
) : (
<span className="text-gray-500"></span>
)}
</TableCell>
<TableCell className="text-right text-xs text-gray-300">
<div className="inline-flex items-center gap-1.5">
<Button
@@ -1391,11 +1717,8 @@ export function UsersPage() {
variant="ghost"
size="sm"
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
onClick={() => {
setSelectedUserIdForDetail(m.id)
setShowDetailModal(true)
}}
title="编辑资料"
onClick={() => openVipWebhookModal(m)}
title="编辑飞书群Webhook"
>
<Edit3 className="w-3.5 h-3.5" />
</Button>
@@ -1418,8 +1741,19 @@ export function UsersPage() {
</Card>
)}
</TabsContent>
</Tabs>
<Dialog open={showMbtiAvatarDialog} onOpenChange={setShowMbtiAvatarDialog}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-6xl">
<DialogHeader>
<DialogTitle className="text-white">MBTI </DialogTitle>
</DialogHeader>
<MbtiAvatarsManager />
</DialogContent>
</Dialog>
{/* ===== 弹框组件 ===== */}
{/* 添加/编辑用户 */}
@@ -1498,6 +1832,38 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* 设置超级个体飞书群 Webhook */}
<Dialog open={showVipWebhookModal} onOpenChange={(open) => { setShowVipWebhookModal(open); if (!open) setVipWebhookModalMember(null) }}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-xl">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
Webhook {vipWebhookModalMember?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Label className="text-gray-300 text-sm">VOX Webhook </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
value={vipWebhookInput}
onChange={(e) => setVipWebhookInput(e.target.value)}
/>
<p className="text-xs text-gray-500">
线
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowVipWebhookModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSetVipWebhook} disabled={vipWebhookSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-4 h-4 mr-2" />{vipWebhookSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
@@ -1517,12 +1883,71 @@ export function UsersPage() {
{/* 添加/编辑规则 */}
<Dialog open={showRuleModal} onOpenChange={setShowRuleModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><PenLine className="w-5 h-5 text-[#38bdac]" />{editingRule ? '编辑规则' : '添加规则'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2"><Label className="text-gray-300"> *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例匹配后填写头像、付款1980需填写信息" value={ruleForm.title} onChange={(e) => setRuleForm({ ...ruleForm, title: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[80px] resize-none" placeholder="详细说明规则内容..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:完成匹配、付款后、注册时" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} /></div>
<div className="space-y-2"><Label className="text-gray-300"></Label><Textarea className="bg-[#0a1628] border-gray-700 text-white min-h-[60px] resize-none" placeholder="弹窗内容/推送文案..." value={ruleForm.description} onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })} /></div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="space-y-2">
{['用户状态', '阅读行为', '付费行为', '社交行为', '分销行为'].map((group) => {
const items = TRIGGER_OPTIONS.filter((o) => o.group === group)
if (items.length === 0) return null
return (
<div key={group}>
<p className="text-[10px] text-gray-500 mb-1">{group}</p>
<div className="flex flex-wrap gap-1.5">
{items.map((opt) => {
const selected = (ruleForm.triggerConditions || []).includes(opt.value)
return (
<button
key={opt.value}
type="button"
className={`px-2.5 py-1 rounded-md text-xs border transition-colors ${selected ? 'bg-[#38bdac]/20 border-[#38bdac]/50 text-[#38bdac]' : 'bg-[#0a1628] border-gray-700 text-gray-400 hover:border-gray-500'}`}
onClick={() => {
const current = ruleForm.triggerConditions || []
const next = selected ? current.filter((v) => v !== opt.value) : [...current, opt.value]
setRuleForm({ ...ruleForm, triggerConditions: next })
}}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
})}
</div>
{(ruleForm.triggerConditions || []).length > 0 && (
<p className="text-[10px] text-[#38bdac]"> {(ruleForm.triggerConditions || []).length} </p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white text-xs h-8" placeholder="与小程序一致注册、完成付款、update_avatar、update_nickname 等" value={ruleForm.trigger} onChange={(e) => setRuleForm({ ...ruleForm, trigger: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="grid grid-cols-2 gap-2">
{ACTION_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`p-2 rounded-lg border text-left transition-colors ${ruleForm.actionType === opt.value ? 'bg-[#38bdac]/15 border-[#38bdac]/50' : 'bg-[#0a1628] border-gray-700 hover:border-gray-500'}`}
onClick={() => setRuleForm({ ...ruleForm, actionType: opt.value })}
>
<span className={`text-xs font-medium ${ruleForm.actionType === opt.value ? 'text-[#38bdac]' : 'text-gray-300'}`}>{opt.label}</span>
<p className="text-[10px] text-gray-500 mt-0.5">{opt.desc}</p>
</button>
))}
</div>
</div>
<div className="flex items-center justify-between"><div><Label className="text-gray-300"></Label></div><Switch checked={ruleForm.enabled} onCheckedChange={(c) => setRuleForm({ ...ruleForm, enabled: c })} /></div>
</div>
<DialogFooter>

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -92,6 +92,8 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
// persons 历史库可能因旧索引冲突导致 AutoMigrate 中断,补一层列级自愈,避免 /api/db/persons 报 Unknown column。
ensurePersonSchema(db)
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
@@ -111,6 +113,12 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
log.Printf("database: user_rules migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
log.Printf("database: user_tracks migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.UserRuleCompletion{}); err != nil {
log.Printf("database: user_rule_completions migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}
@@ -119,3 +127,22 @@ func Init(dsn string) error {
func DB() *gorm.DB {
return db
}
func ensurePersonSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.Person{}, "is_pinned") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '置顶到小程序首页'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "person_source") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN person_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源:空=后台手工vip_sync=超级个体同步'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add person_source", err)
}
}
if !m.HasIndex(&model.Person{}, "idx_persons_is_pinned") {
if err := db.Exec("CREATE INDEX idx_persons_is_pinned ON persons(is_pinned)").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
}
}
}

View File

@@ -307,7 +307,7 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
}
// AdminSuperIndividualStats GET /api/admin/super-individual/stats
// 超级个体点击/获客统计:从 user_tracks 中筛选 target LIKE '超级个体_%' 的记录
// 超级个体点击/获客统计:从 user_tracks 中筛选「点击头像」记录(target LIKE '链接头像_%'
// 按被点击的超级个体 ID 分组,统计点击次数、独立点击用户数
func AdminSuperIndividualStats(c *gin.Context) {
db := database.DB()
@@ -324,7 +324,8 @@ func AdminSuperIndividualStats(c *gin.Context) {
COUNT(*) AS clicks,
COUNT(DISTINCT user_id) AS unique_clicks
FROM user_tracks
WHERE action = 'card_click' AND target LIKE '超级个体\_%'
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
GROUP BY target_id
ORDER BY clicks DESC
`).Scan(&rows).Error; err != nil {

View File

@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const mbtiAvatarsConfigKey = "mbti_avatars"
const mbtiAvatarsDescription = "MBTI 16型人格头像映射"
// AdminMbtiAvatarsGet GET /api/admin/mbti-avatars 读取 MBTI 头像映射system_config.mbti_avatars
func AdminMbtiAvatarsGet(c *gin.Context) {
db := database.DB()
var row model.SystemConfig
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取配置失败: " + err.Error()})
return
}
out := make(map[string]string)
if len(row.ConfigValue) > 0 {
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置 JSON 无效: " + uerr.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
}
// AdminMbtiAvatarsPost POST /api/admin/mbti-avatars 保存 MBTI 头像映射upsert
func AdminMbtiAvatarsPost(c *gin.Context) {
var body struct {
Avatars map[string]string `json:"avatars"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
avatars := body.Avatars
if avatars == nil {
avatars = map[string]string{}
}
valBytes, err := json.Marshal(avatars)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "序列化失败: " + err.Error()})
return
}
db := database.DB()
desc := mbtiAvatarsDescription
var row model.SystemConfig
err = db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.SystemConfig{
ConfigKey: mbtiAvatarsConfigKey,
ConfigValue: valBytes,
Description: &desc,
}
if cerr := db.Create(&row).Error; cerr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + cerr.Error()})
return
}
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "查询配置失败: " + err.Error()})
return
}
} else {
row.ConfigValue = valBytes
row.Description = &desc
if serr := db.Save(&row).Error; serr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + serr.Error()})
return
}
}
_mbtiAvatarCacheTs = 0
c.JSON(http.StatusOK, gin.H{"success": true, "message": "MBTI 头像映射已保存"})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
@@ -37,7 +38,8 @@ func DBUserRulesList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
}
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules?userId=xxx
// 返回启用的规则,并标记当前用户已完成的规则
func MiniprogramUserRulesGet(c *gin.Context) {
db := database.DB()
var rules []model.UserRule
@@ -45,7 +47,43 @@ func MiniprogramUserRulesGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
userId := c.Query("userId")
completedSet := make(map[uint]bool)
if userId != "" {
var completions []model.UserRuleCompletion
db.Where("user_id = ?", userId).Find(&completions)
for _, comp := range completions {
completedSet[comp.RuleID] = true
}
}
out := make([]gin.H, 0, len(rules))
for _, r := range rules {
out = append(out, gin.H{
"id": r.ID, "title": r.Title, "description": r.Description,
"trigger": r.Trigger, "sort": r.Sort, "completed": completedSet[r.ID],
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "rules": out})
}
// MiniprogramUserRuleComplete POST /api/miniprogram/user-rules/complete
func MiniprogramUserRuleComplete(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
RuleID uint `json:"ruleId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
comp := model.UserRuleCompletion{UserID: body.UserID, RuleID: body.RuleID}
result := db.Where("user_id = ? AND rule_id = ?", body.UserID, body.RuleID).FirstOrCreate(&comp)
if result.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "alreadyCompleted": result.RowsAffected == 0})
}
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
@@ -55,11 +93,14 @@ func DBUserRulesAction(c *gin.Context) {
switch c.Request.Method {
case http.MethodPost:
var body struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -73,9 +114,20 @@ func DBUserRulesAction(c *gin.Context) {
Title: trimSpace(body.Title),
Description: body.Description,
Trigger: trimSpace(body.Trigger),
ActionType: trimSpace(body.ActionType),
Sort: body.Sort,
Enabled: enabled,
}
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
rule.TriggerConditions = b
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
rule.ActionConfig = b
}
}
if err := db.Create(&rule).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
@@ -84,12 +136,15 @@ func DBUserRulesAction(c *gin.Context) {
case http.MethodPut:
var body struct {
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
ID uint `json:"id" binding:"required"`
Title string `json:"title"`
Description string `json:"description"`
Trigger string `json:"trigger"`
TriggerConditions interface{} `json:"triggerConditions"`
ActionType string `json:"actionType"`
ActionConfig interface{} `json:"actionConfig"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
@@ -110,6 +165,17 @@ func DBUserRulesAction(c *gin.Context) {
}
updates["description"] = body.Description
updates["trigger"] = trimSpace(body.Trigger)
updates["action_type"] = trimSpace(body.ActionType)
if body.TriggerConditions != nil {
if b, err := json.Marshal(body.TriggerConditions); err == nil {
updates["trigger_conditions"] = string(b)
}
}
if body.ActionConfig != nil {
if b, err := json.Marshal(body.ActionConfig); err == nil {
updates["action_config"] = string(b)
}
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}

View File

@@ -85,6 +85,8 @@ type cachedPartRow struct {
Subtitle string `json:"subtitle"`
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
// Icon 可选system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
Icon string `json:"icon,omitempty"`
}
type cachedFixedItem struct {
ID string `json:"id"`
@@ -109,6 +111,48 @@ var bookPartsCache struct {
const bookPartsCacheTTL = 30 * time.Second
// loadBookPartIconURLs 读取 system_config.book_part_icons{"part-1":"https://..."}key 与 chapters.part_id 一致
func loadBookPartIconURLs() map[string]string {
out := map[string]string{}
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "book_part_icons").First(&row).Error; err != nil {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out[k] = s
}
}
}
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts每次接口响应前调用避免 Redis 旧缓存缺 icon
func mergeBookPartIcons(parts []cachedPartRow) {
if len(parts) == 0 {
return
}
m := loadBookPartIconURLs()
if len(m) == 0 {
return
}
for i := range parts {
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
parts[i].Icon = u
}
}
}
// chaptersByPartCache 篇章内章节列表内存缓存30 秒 TTL
type chaptersByPartEntry struct {
data []model.Chapter
@@ -320,6 +364,7 @@ func BookParts(c *gin.Context) {
// 1. 优先 Redis后台无更新时长期有效
var redisPayload bookPartsRedisPayload
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
mergeBookPartIcons(redisPayload.Parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": redisPayload.Parts,
@@ -336,6 +381,7 @@ func BookParts(c *gin.Context) {
total := bookPartsCache.total
fixed := bookPartsCache.fixed
bookPartsCache.mu.RUnlock()
mergeBookPartIcons(parts)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
@@ -348,6 +394,7 @@ func BookParts(c *gin.Context) {
// 3. DB 查询并更新 Redis + 内存
parts, total, fixed := fetchAndCacheBookParts()
mergeBookPartIcons(parts)
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)

View File

@@ -7,11 +7,13 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -443,6 +445,22 @@ func CKBIndexLead(c *gin.Context) {
}
}
data["repeatedSubmit"] = repeatedSubmit
personName := "卡若"
if defaultPerson.Name != "" {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
}
@@ -472,13 +490,15 @@ func CKBIndexLead(c *gin.Context) {
// 请求体phone/wechatId至少一个、userId补全昵称、targetUserIdPerson.token、targetNickname、source如 article_mention、member_detail_avatar
func CKBLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention 场景
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
Source string `json:"source"` // index_lead / article_mention
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention / 超级个体人物 token
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -504,6 +524,7 @@ func CKBLead(c *gin.Context) {
// 首页链接卡若targetUserId 为空 → 用全局 getCkbLeadApiKey()
leadKey := getCkbLeadApiKey()
targetName := strings.TrimSpace(body.TargetNickname)
targetMemberID := strings.TrimSpace(body.TargetMemberID)
personTips := "" // Person 配置的获客成功提示,优先于默认文案
if body.TargetUserID != "" {
var p model.Person
@@ -520,6 +541,11 @@ func CKBLead(c *gin.Context) {
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
@@ -536,7 +562,8 @@ func CKBLead(c *gin.Context) {
}
paramsJSON, _ := json.Marshal(map[string]interface{}{
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"targetUserId": body.TargetUserID, "source": source,
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
@@ -611,7 +638,17 @@ func CKBLead(c *gin.Context) {
}
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
return
@@ -675,7 +712,17 @@ func CKBLead(c *gin.Context) {
} else {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
@@ -702,10 +749,82 @@ func CKBLead(c *gin.Context) {
c.JSON(http.StatusOK, respObj)
}
func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, repeated bool) {
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
}
func leadSourceLabel(source string) string {
switch source {
case "member_detail_global":
return "超级个体详情页·全局链接"
case "member_detail_avatar":
return "超级个体详情页·点击头像"
case "article_mention":
return "文章正文·@提及人物"
case "index_link_button":
return "首页·链接卡若按钮"
case "index_lead":
return "首页·留资弹窗"
default:
if source == "" {
return "未知来源"
}
return source
}
}
var _webhookDedupCache = struct {
sync.Mutex
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string, targetMemberID string) bool {
if userId == "" && targetMemberID == "" {
return false
}
today := time.Now().Format("2006-01-02")
key := strings.TrimSpace(userId) + "|" + strings.TrimSpace(targetMemberID)
if key == "|" {
return false
}
_webhookDedupCache.Lock()
defer _webhookDedupCache.Unlock()
if _webhookDedupCache.m[key] == today {
return true
}
_webhookDedupCache.m[key] = today
if len(_webhookDedupCache.m) > 10000 {
_webhookDedupCache.m = map[string]string{key: today}
}
return false
}
func loadLeadWebhookURL(db *gorm.DB, targetMemberID string) string {
// 优先按超级个体 userId 映射(单人单群)
targetMemberID = strings.TrimSpace(targetMemberID)
if targetMemberID != "" {
var mapCfg model.SystemConfig
if err := db.Where("config_key = ?", superIndividualWebhookConfigKey).First(&mapCfg).Error; err == nil && len(mapCfg.ConfigValue) > 0 {
var m map[string]string
if json.Unmarshal(mapCfg.ConfigValue, &m) == nil {
if u := strings.TrimSpace(m[targetMemberID]); u != "" && strings.HasPrefix(u, "http") {
return u
}
}
}
}
// 回退全局获客 webhook
var cfg model.SystemConfig
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
return
return ""
}
var webhookURL string
if len(cfg.ConfigValue) > 0 {
@@ -713,22 +832,56 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
}
webhookURL = strings.TrimSpace(webhookURL)
if webhookURL == "" || !strings.HasPrefix(webhookURL, "http") {
return ""
}
return webhookURL
}
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID, p.TargetMemberID) {
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
return
}
webhookURL := loadLeadWebhookURL(db, p.TargetMemberID)
if webhookURL == "" {
return
}
tag := "新获客"
if repeated {
tag = "重复获客"
tag := "📋 新获客"
if p.Repeated {
tag = "🔄 重复获客"
}
text := fmt.Sprintf("[%s] %s → %s\n姓名: %s", tag, source, target, name)
if phone != "" {
text += fmt.Sprintf("\n手机: %s", phone)
sourceLabel := leadSourceLabel(p.Source)
contactPerson := p.PersonName
if contactPerson == "" {
contactPerson = p.MemberName
}
if wechat != "" {
text += fmt.Sprintf("\n微信: %s", wechat)
if contactPerson == "" || contactPerson == "对方" {
contactPerson = "(公共获客池)"
}
text := fmt.Sprintf("%s\n来源: %s\n对接人: %s", tag, sourceLabel, contactPerson)
text += "\n━━━━━━━━━━"
text += fmt.Sprintf("\n姓名: %s", p.LeadName)
if p.Phone != "" {
text += fmt.Sprintf("\n手机: %s", p.Phone)
}
if p.Wechat != "" {
text += fmt.Sprintf("\n微信: %s", p.Wechat)
}
text += fmt.Sprintf("\n时间: %s", time.Now().Format("2006-01-02 15:04"))
if p.LeadUserID != "" {
recentTracks := GetUserRecentTracks(db, p.LeadUserID, 5)
if len(recentTracks) > 0 {
text += "\n━━━━━━━━━━\n最近行为:"
for i, line := range recentTracks {
text += fmt.Sprintf("\n %d. %s", i+1, line)
}
}
}
var payload []byte
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
payload, _ = json.Marshal(map[string]interface{}{
@@ -747,5 +900,5 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
return
}
defer resp.Body.Close()
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", target, resp.StatusCode)
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
}

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