diff --git a/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md b/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md new file mode 100644 index 00000000..173f3477 --- /dev/null +++ b/.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md @@ -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。 diff --git a/.cursor/agent/开发助理/evolution/索引.md b/.cursor/agent/开发助理/evolution/索引.md index 39773d58..7dfccffb 100644 --- a/.cursor/agent/开发助理/evolution/索引.md +++ b/.cursor/agent/开发助理/evolution/索引.md @@ -4,4 +4,5 @@ | 日期 | 摘要 | 文件 | |------|------|------| +| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md | | 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts) | 2026-03-16-交互习惯分析.md | diff --git a/.cursor/docs/feishu_开发群与项目复盘.md b/.cursor/docs/feishu_开发群与项目复盘.md new file mode 100644 index 00000000..e572a2de --- /dev/null +++ b/.cursor/docs/feishu_开发群与项目复盘.md @@ -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** 推送配置统一指向本开发群,避免复盘散落多个群。 diff --git a/.cursor/skills/karuo-party/SKILL.md b/.cursor/skills/karuo-party/SKILL.md index acf194c6..b0eabfe1 100644 --- a/.cursor/skills/karuo-party/SKILL.md +++ b/.cursor/skills/karuo-party/SKILL.md @@ -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 v2(msg_type 必填) | | 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 | diff --git a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md index e0c023df..ef34db9a 100644 --- a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md +++ b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md @@ -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" --- -# 多平台分发 Skill(v4.0) +# 多平台分发 Skill(v4.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 条立即;间隔与总跨度随条数自适应;本地 0–7 点尽量挪到午间(`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`。 --- diff --git a/.cursor/skills/karuo-party/skills/平台_B站_SKILL.md b/.cursor/skills/karuo-party/skills/平台_B站_SKILL.md new file mode 100644 index 00000000..1b377cfe --- /dev/null +++ b/.cursor/skills/karuo-party/skills/平台_B站_SKILL.md @@ -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" +--- + +# 平台子Skill:B站发布 + +## 一、强制规则 + +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 +``` + diff --git a/.cursor/skills/karuo-party/skills/平台_小红书_SKILL.md b/.cursor/skills/karuo-party/skills/平台_小红书_SKILL.md new file mode 100644 index 00000000..88ba07b4 --- /dev/null +++ b/.cursor/skills/karuo-party/skills/平台_小红书_SKILL.md @@ -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 +``` + diff --git a/.cursor/skills/karuo-party/skills/平台_抖音_SKILL.md b/.cursor/skills/karuo-party/skills/平台_抖音_SKILL.md new file mode 100644 index 00000000..316b102a --- /dev/null +++ b/.cursor/skills/karuo-party/skills/平台_抖音_SKILL.md @@ -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 +``` + diff --git a/.cursor/skills/karuo-party/skills/平台_视频号_SKILL.md b/.cursor/skills/karuo-party/skills/平台_视频号_SKILL.md new file mode 100644 index 00000000..520b5b4f --- /dev/null +++ b/.cursor/skills/karuo-party/skills/平台_视频号_SKILL.md @@ -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 +``` + diff --git a/.gitignore b/.gitignore index 63783f2e..6d17c5ed 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ soul-api/soul-api-new # Cursor 索引减负:db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径) .cursor/scripts/db-exec/node_modules/ + +# 本地技能包临时打包目录 +.tmp_skill_bundle/ diff --git a/miniprogram/app.js b/miniprogram/app.js index 3098061c..2c1f4a66 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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 === false,soul-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 diff --git a/miniprogram/app.json b/miniprogram/app.json index feeafc49..8dcb6322 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/assets/images/karuo-link-avatar.png b/miniprogram/assets/images/karuo-link-avatar.png new file mode 100644 index 00000000..e059c5b6 Binary files /dev/null and b/miniprogram/assets/images/karuo-link-avatar.png differ diff --git a/miniprogram/assets/images/part-books/0.png b/miniprogram/assets/images/part-books/0.png new file mode 100644 index 00000000..ed3dc4a0 Binary files /dev/null and b/miniprogram/assets/images/part-books/0.png differ diff --git a/miniprogram/assets/images/part-books/1.png b/miniprogram/assets/images/part-books/1.png new file mode 100644 index 00000000..29cad917 Binary files /dev/null and b/miniprogram/assets/images/part-books/1.png differ diff --git a/miniprogram/assets/images/part-books/2.png b/miniprogram/assets/images/part-books/2.png new file mode 100644 index 00000000..0bc8f47e Binary files /dev/null and b/miniprogram/assets/images/part-books/2.png differ diff --git a/miniprogram/assets/images/part-books/3.png b/miniprogram/assets/images/part-books/3.png new file mode 100644 index 00000000..ed3dc4a0 Binary files /dev/null and b/miniprogram/assets/images/part-books/3.png differ diff --git a/miniprogram/assets/images/part-books/4.png b/miniprogram/assets/images/part-books/4.png new file mode 100644 index 00000000..29cad917 Binary files /dev/null and b/miniprogram/assets/images/part-books/4.png differ diff --git a/miniprogram/components/login-modal/login-modal.wxml b/miniprogram/components/login-modal/login-modal.wxml index b07bc3c7..f821e40e 100644 --- a/miniprogram/components/login-modal/login-modal.wxml +++ b/miniprogram/components/login-modal/login-modal.wxml @@ -6,14 +6,14 @@ 登录 卡若创业派对 {{desc}} - 为获取手机号,请先同意《用户隐私保护指引》 - + 取消 diff --git a/miniprogram/components/login-modal/login-modal.wxss b/miniprogram/components/login-modal/login-modal.wxss index 116bef83..7084b74b 100644 --- a/miniprogram/components/login-modal/login-modal.wxss +++ b/miniprogram/components/login-modal/login-modal.wxss @@ -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; } diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js index 27268ea8..6b27182d 100644 --- a/miniprogram/custom-tab-bar/index.js +++ b/miniprogram/custom-tab-bar/index.js @@ -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() diff --git a/miniprogram/pages/about/about.wxml b/miniprogram/pages/about/about.wxml index 2987434e..aaee8f00 100644 --- a/miniprogram/pages/about/about.wxml +++ b/miniprogram/pages/about/about.wxml @@ -61,7 +61,7 @@ - + 联系作者 diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.js b/miniprogram/pages/avatar-nickname/avatar-nickname.js index 1de09a3b..5293a5b5 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.js +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.js @@ -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() { diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.json b/miniprogram/pages/avatar-nickname/avatar-nickname.json index 4d1ce1a1..c9671edb 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.json +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.json @@ -1,4 +1,4 @@ { - "navigationBarTitleText": "完善资料", + "navigationBarTitleText": "头像与昵称", "usingComponents": {} } diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml index e844082b..6a920990 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml @@ -1,22 +1,23 @@ - + - 完善资料 + 头像与昵称 - - 完善头像和昵称 - 让他人更好地认识你,展示更专业的形象 + 设置对外展示信息 + 请先换一张清晰头像,伙伴更容易认出你。 + 请改一个真实好记的昵称,方便伙伴称呼你。 + 头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。 - + + 拒绝 + + + + - 留下联系方式 方便卡若与您联系 - - - 为获取手机号,请先同意《用户隐私保护指引》 - - + 或手动输入 diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index f8c22e4c..7fc9b66f 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -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; diff --git a/miniprogram/pages/match/match.js b/miniprogram/pages/match/match.js index f7cd7f50..4ac3403c 100644 --- a/miniprogram/pages/match/match.js +++ b/miniprogram/pages/match/match.js @@ -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) - // 规范化 commonInterests:emoji 或无效 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) diff --git a/miniprogram/pages/match/match.wxml b/miniprogram/pages/match/match.wxml index eb9d8fb5..24d3146b 100644 --- a/miniprogram/pages/match/match.wxml +++ b/miniprogram/pages/match/match.wxml @@ -121,7 +121,8 @@ - + + {{currentMatch.nickname ? currentMatch.nickname[0] : '?'}} {{currentMatch.nickname}} diff --git a/miniprogram/pages/match/match.wxss b/miniprogram/pages/match/match.wxss index 7852ded2..106966af 100644 --- a/miniprogram/pages/match/match.wxss +++ b/miniprogram/pages/match/match.wxss @@ -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; diff --git a/miniprogram/pages/member-detail/member-detail.js b/miniprogram/pages/member-detail/member-detail.js index 1edfe3f7..48a7468a 100644 --- a/miniprogram/pages/member-detail/member-detail.js +++ b/miniprogram/pages/member-detail/member-detail.js @@ -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 } }) diff --git a/miniprogram/pages/member-detail/member-detail.wxml b/miniprogram/pages/member-detail/member-detail.wxml index c28ff406..2af31522 100644 --- a/miniprogram/pages/member-detail/member-detail.wxml +++ b/miniprogram/pages/member-detail/member-detail.wxml @@ -1,20 +1,29 @@ - + - 个人资料 - + {{isOwnProfile ? '我的名片' : '个人资料'}} + + 编辑 + + - + - + @@ -30,10 +39,39 @@ {{member.region}} + 点头像 · 申请对接 + + + + + + {{(member.name && member.name[0]) || '创'}} + + VIP + + {{member.name}} + + {{member.mbti}} + + + {{member.region}} + + + 这是我的超级个体名片 · 可转发分享 · 点头像去编辑 - + + 联系方式 - - - 暂未公开联系方式 - diff --git a/miniprogram/pages/member-detail/member-detail.wxss b/miniprogram/pages/member-detail/member-detail.wxss index 52ed1939..4b5d489c 100644 --- a/miniprogram/pages/member-detail/member-detail.wxss +++ b/miniprogram/pages/member-detail/member-detail.wxss @@ -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; diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index d99fdd40..c4175f1b 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -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 || '') - } - } + /** 本地已打开的章节 id(reading_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-detail;mpUi.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' }) } } diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index e41e7836..0004fba4 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -1,10 +1,7 @@ - + - - - 我的 @@ -23,36 +20,30 @@ - + - + {{userInfo.nickname ? userInfo.nickname[0] : '?'}} VIP VIP - {{userInfo.nickname || '点击设置昵称'}} - - {{isVip ? '会员中心' : '成为会员'}} - - - 会员 - 匹配 - 排行 + + {{mpUiCardLabel}} + {{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}} - 微信号: {{userWechat}} - + {{readCountText || '0'}} - 已读章节 + {{mpUiReadStatLabel}} - + {{referralCount}} 推荐好友 @@ -60,7 +51,7 @@ {{matchHistoryText}} 匹配伙伴 - + {{pendingEarnings || '0.00'}} 我的收益 @@ -117,43 +108,13 @@ - - - - - - - - - {{index + 1}} - {{item.title}} - - 阅读 - - - - - - + - + - 最近阅读 + {{mpUiRecentTitle}} 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' }) diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index b83adc38..851cef9e 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -1,158 +1,311 @@ - + - 编辑资料 + {{wizardMode ? '分步档案(' + wizardStep + '/3)' : '编辑资料'}} 加载中... - - - - {{fromVip ? '恭喜成为VIP!完善资料后即可使用找伙伴、提现等功能,手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}} - - - - - - - - - - - 昵称 - - - - 微信用户可点击自动填充昵称,或手动输入 + + + + 1 + + 2 + + 3 - - - MBTI - - {{mbti || '请选择'}} - + 分步完善,保存后将进入「我的超级个体名片」,可转发分享 + + + + + 第 1 步:先设置对外展示的头像与昵称 - - 地区 - - - + + + + + + 昵称* + + + + 可用微信昵称或手动输入 - - - 行业 - - - - 业务体量 - - - - 职位 - - - - 我擅长 - - - + - - - - - 核心联系方式 - - - 手机号* - - - - 微信号 - - - + + + + 第 2 步:补充职业画像(可后补,尽量填写便于匹配) + + + + + MBTI + + {{mbti || '请选择'}} + + + + 地区 + + + + + + + + 行业 + + + + 业务体量 + + + + 职位 + + + + 我擅长 + + + + - - - - - 个人故事 - - - 你最赚钱的一个月做的是什么 -